v1.4.5: 게시물 작성자·편집 링크·목록 요약 보정

- posts.author_id 마이그레이션 및 owner/admin 단일 계정 환경에서만 기존 글 backfill
- 공개 상세: 글쓴이 본인일 때만 공유 옆 수정 링크 표시, 수정 시각 제거
- 목록 요약: excerpt 없을 때 본문 fallback, post-summary-clamp로 말줄임 처리
- 회원 세션 API에 isAdmin·role 추가
This commit is contained in:
2026-05-22 14:43:22 +09:00
parent 8f53210756
commit 38ca3a4709
16 changed files with 215 additions and 47 deletions

View File

@@ -153,6 +153,27 @@
animation: site-search-modal-in 0.18s ease-out;
}
.post-summary-clamp {
display: -webkit-box;
overflow: hidden;
-webkit-box-orient: vertical;
}
.post-summary-clamp--one {
-webkit-line-clamp: 1;
line-clamp: 1;
}
.post-summary-clamp--two {
-webkit-line-clamp: 2;
line-clamp: 2;
}
.post-summary-clamp--three {
-webkit-line-clamp: 3;
line-clamp: 3;
}
.post-prose {
@apply max-w-none text-[17px] leading-8;
color: var(--site-text);

View File

@@ -23,7 +23,10 @@ defineProps({
{{ post.title }}
</NuxtLink>
</h2>
<p class="post-card__excerpt mt-2 text-sm leading-6 site-muted">
<p
v-if="post.excerpt"
class="post-card__excerpt post-summary-clamp post-summary-clamp--two mt-2 text-sm leading-6 site-muted"
>
{{ post.excerpt }}
</p>
<p class="post-card__meta mt-2 text-xs site-muted">

View File

@@ -0,0 +1,39 @@
/**
* 게시물 요약 또는 본문에서 목록·메타용 짧은 텍스트를 만든다.
* @param {string} excerpt - 게시물 요약
* @param {string} content - 게시물 본문(마크다운)
* @param {Object} [options] - 옵션
* @param {number} [options.maxLength=160] - 최대 글자 수
* @param {boolean} [options.appendEllipsis=true] - 잘린 문자열 끝에 말줄임 추가 여부
* @returns {string} 화면 표시용 요약
*/
export function createPostSummary(excerpt = '', content = '', options = {}) {
const maxLength = Number(options.maxLength) > 0 ? Number(options.maxLength) : 160
const appendEllipsis = options.appendEllipsis !== false
const source = String(excerpt || '').trim() || String(content || '')
const plainText = source
.replace(/```[\s\S]*?```/g, ' ')
.replace(/:::[\s\S]*?:::/g, ' ')
.replace(/<!--[\s\S]*?-->/g, ' ')
.replace(/!\[[^\]]*]\([^)]*\)/g, ' ')
.replace(/\[([^\]]+)]\([^)]*\)/g, '$1')
.replace(/https?:\/\/\S+/g, ' ')
.replace(/[#>*_`~|-]+/g, ' ')
.replace(/\s+/g, ' ')
.trim()
if (!plainText) {
return ''
}
if (plainText.length <= maxLength) {
return plainText
}
if (!appendEllipsis) {
return plainText.slice(0, maxLength).trim()
}
return `${plainText.slice(0, maxLength - 3).trim()}...`
}

View File

@@ -0,0 +1,24 @@
ALTER TABLE posts
ADD COLUMN IF NOT EXISTS author_id UUID REFERENCES users(id) ON DELETE SET NULL;
UPDATE posts
SET author_id = (
SELECT id
FROM (
SELECT id
FROM users
WHERE user_role IN ('owner', 'admin')
OR is_admin = true
) privileged_users
LIMIT 1
)
WHERE author_id IS NULL
AND (
SELECT COUNT(*)
FROM users
WHERE user_role IN ('owner', 'admin')
OR is_admin = true
) = 1;
CREATE INDEX IF NOT EXISTS posts_author_id_idx
ON posts (author_id);

View File

@@ -1,5 +1,9 @@
# 의사결정 이력
## 2026-05-22 v1.4.5 — 게시물 작성자 기준 편집 링크
공개 게시글 상세의 편집 버튼은 단순히 “관리자 로그인 여부”가 아니라 실제 글쓴이인지로 판단해야 한다. 현재 운영은 관리자 1인 작성 전제지만, 멤버·권한 구조가 이미 분리되어 있으므로 게시물에 `author_id`를 명시해 현재 로그인 회원 ID와 비교하는 방식으로 정리한다. 기존 게시물은 owner/admin 계정이 정확히 1개일 때만 backfill해 잘못된 작성자 배정을 피하고, 새 게시물은 관리자 세션의 사용자 ID를 작성자로 저장한다.
## 2026-05-21 v1.4.2 — 관리자 레이아웃 라이트 테마 격리
공개 사이트의 라이트/다크 테마는 `html[data-theme]`와 CSS 변수로 전역에 적용된다. 같은 앱 안의 관리자 화면이 이 변수를 그대로 상속하면, 공개 화면을 다크모드로 둔 상태에서 관리자 네비게이션 입력처럼 별도 배경색을 명시하지 않은 폼 컨트롤이 어두운 색으로 바뀌어 관리 UI 가독성이 깨진다. 관리자 로그인은 별도 다크 인증 화면으로 유지하되, 로그인 이후 `admin-layout`은 운영 도구 성격에 맞춰 라이트 UI로 고정한다. 다만 글쓰기 에디터는 별도 입력 UX가 있으므로, 폼 컨트롤 `color-scheme` 재정의는 `admin-layout--light-controls`가 붙은 일반 관리자 화면에만 적용한다.

View File

@@ -17,6 +17,7 @@
| 파일 | 용도 |
|------|------|
| composables/formatPostDate.js | 공개 게시일 `YYYY.MM.DD`, 관리자·수정일 보조 `formatPostDateTime`, `wasPostUpdatedAfterPublish` |
| composables/createPostSummary.js | 게시물 요약·본문에서 목록·SEO용 짧은 설명 텍스트 생성 |
| composables/useAdminToast.js | 관리자 우측 상단 토스트(성공·오류·안내; 모달보다 위 z-index) |
## 공유 라이브러리(서버·클라이언트 공통)
@@ -150,7 +151,7 @@
| pages/index.vue | 홈, `site_settings` 커버가 있을 때만 `HomeHero`, Featured/Latest, Latest 피드 Compact 기본값·List·Cards 보기, Latest 메타는 태그가 있을 때만 태그 배지 표시, Featured는 추천 글이 있을 때만 표시하며 이미지 없는 추천 글은 제목 placeholder 썸네일과 모바일 터치 가로 스크롤·스냅, 끝에서 화살표 비활성 |
| pages/posts/index.vue | 게시물 전체 목록, 태그는 있을 때만 카드 메타에 표시 |
| pages/posts/[slug].vue | `/post/:slug` 리다이렉트 |
| pages/post/[slug].vue | 블로그 글 상세, 첫 태그가 있을 때만 메타 행에 태그 링크, 게시물 SEO/OG 메타 출력, 공유 모달(X/Bluesky/Facebook/LinkedIn/Email/링크복사), 회원 댓글 섹션 |
| pages/post/[slug].vue | 블로그 글 상세, 첫 태그가 있을 때만 메타 행에 태그 링크, 로그인 회원이 글쓴이(`author_id`)이면 공유 버튼 옆 새 탭 편집 링크 표시, 게시물 SEO/OG 메타 출력(요약 없으면 본문 fallback), 공유 모달(X/Bluesky/Facebook/LinkedIn/Email/링크복사), 회원 댓글 섹션 |
| pages/tags/index.vue | 태그 전체 목록, 중앙 히어로와 3열 태그 카드, 좌측 컬러 보더/hover 오버레이 |
| pages/tags/[slug].vue | `/tag/:slug` 리다이렉트 |
| pages/tag/[slug].vue | 태그별 글 목록, 상단 태그 헤더 + 공통 섹션 패딩을 쓰는 리스트형 게시물 카드 |
@@ -180,7 +181,7 @@
| server/utils/email-otp.js | OTP 생성·해시 |
| server/utils/resend-mail.js | Resend REST 발송 |
| server/api/auth/login.post.js | 회원 로그인 API |
| server/api/auth/me.get.js | 회원 세션 조회 API |
| server/api/auth/me.get.js | 회원 세션 조회 API(`isAdmin`, `role` 포함) |
| server/api/auth/logout.post.js | 회원 로그아웃 API |
| server/api/auth/profile.get.js | 회원 프로필 조회 API |
| server/api/auth/profile.put.js | 회원 프로필 수정 API(닉네임·`avatarUrl`; 관리 썸네일 URL 교체 시 메타만 분리) |
@@ -283,6 +284,7 @@
| db/migrations/015_add_tag_type_and_reorder_support.sql | 태그 유형(`managed`/`general`) 컬럼 추가 |
| db/migrations/030_analytics_daily_stats.sql | 사이트·게시물 일별 통계·일별 방문자 해시 테이블 |
| db/migrations/031_analytics_engagement_and_realtime.sql | 체류·스크롤 집계 컬럼·실시간 접속 세션 테이블 |
| db/migrations/032_add_post_author.sql | 게시물 작성자(`posts.author_id`) 컬럼 추가 및 기존 글 owner/admin backfill |
## 설정/배포

View File

@@ -74,13 +74,16 @@
- 댓글 아바타 이미지 로드 실패 시 이니셜 아바타로 즉시 대체한다.
- 공개 게시물 본문은 콘텐츠 타입별 컴포넌트로 분리해 추후 스타일 변경이 쉽도록 구성
- 제목 우측 공유 버튼을 누르면 게시물 공유 모달을 연다.
- 로그인 회원 ID가 게시물 `author_id`와 같으면 제목 우측 공유 버튼 옆에 수정 아이콘을 표시하며, 클릭 시 `/admin/posts/:id` 편집 화면을 새 탭으로 연다.
- 공유 모달은 게시물 썸네일/제목/요약 미리보기, X/Bluesky/Facebook/LinkedIn/Email 링크, 링크 복사 액션을 제공한다.
- 공유·SEO 설명은 SEO 설명이 있으면 우선 사용하고, 없으면 게시물 요약, 요약도 없으면 본문에서 마크다운 기호를 제거한 짧은 텍스트를 사용한다.
- 홈 Latest·게시물 목록·태그 목록의 카드 설명도 동일하게 요약이 비어 있으면 본문에서 `createPostSummary`로 짧은 텍스트를 만든다. 목록용 설명은 문자열에 수동 말줄임을 붙이지 않고 `post-summary-clamp` 전용 클래스가 실제 표시 줄 끝에서 말줄임을 처리한다.
### 공개 목록·상세의 발행일 표시
- API의 ISO 8601 `publishedAt`를 공개 UI에서는 로컬 날짜 기준 `YYYY.MM.DD`로 표시한다.
- 변환은 `composables/formatPostDate.js``formatPostDate`를 사용한다.
- 관리자 목록·수정일 보조 라벨은 `formatPostDateTime`(`YYYY.MM.DD 오전/오후 HH:MM`)을 사용한다. 발행 후 수정 여부는 `wasPostUpdatedAfterPublish`로 판별하며, `site_settings.show_post_updated_at`이 true일 때만 「수정: …」를 노출한다.
- 관리자 목록·수정일 보조 라벨은 `formatPostDateTime`(`YYYY.MM.DD 오전/오후 HH:MM`)을 사용한다. 발행 후 수정 여부는 `wasPostUpdatedAfterPublish`로 판별하며, `site_settings.show_post_updated_at`이 true일 때만 관리자 글 목록에 「수정: …」를 노출한다. 공개 게시글 상세에는 수정 시각을 표시하지 않는다.
- `<time>`에는 표시용 문자열과 함께 가능한 경우 원본 시각을 `datetime` 속성으로 둔다.
### Page 페이지
@@ -435,7 +438,7 @@ components/content/
- `POST /api/auth/email-otp/request` - 본문: `email`, `purpose`(`"signup"` | `"password_reset"`). Resend로 6자리 OTP 메일을 보낸다. 미설정 시 503. `signup`은 이미 가입된 이메일이면 409, 최초 관리자 단계에서는 400. `password_reset`은 존재하지 않는 이메일도 동일한 성공 메시지를 반환하며(이메일 미발송), 요청 빈도 제한을 위해 DB에 더미 챌린지를 남길 수 있다. 55초 쿨다운·시간당 5회 한도. 실제 메일 발송이 실패하면 방금 생성한 챌린지는 즉시 삭제한다. 발송 성공 후 같은 이메일·용도의 이전 pending 챌린지는 무효화한다. DB 테이블 `email_otp_challenges`(마이그레이션 `018_email_otp_challenges.sql`) 필요.
- `POST /api/auth/password-reset/confirm` - 본문: `email`, `code`(6자리), `newPassword`(8~32자). `password_reset` OTP 검증·소진 후 해당 이메일 회원 비밀번호를 갱신한다.
- `POST /api/auth/login` - 회원 로그인
- `GET /api/auth/me` - 현재 회원 세션 조회
- `GET /api/auth/me` - 현재 회원 세션 조회(`id`, `username`, `email`, `avatarUrl`, `isAdmin`, `role`)
- `POST /api/auth/logout` - 회원 로그아웃
- `GET /api/auth/profile` - 회원 설정 조회
- `PUT /api/auth/profile` - 회원 프로필 수정(닉네임, `avatarUrl`). 이전 값이 `/uploads/members/avatars/` URL이고 새 값과 달라지면 `removeManagedAvatarAsset`으로 **메타만** 끊고 디스크 파일은 유지한다(`DELETE /api/auth/avatar`와 동일한 자산 정리 규칙).
@@ -636,7 +639,7 @@ components/content/
- 사이트 설정은 `site_settings` 테이블의 단일 레코드로 관리한다.
- 관리자는 사이트 이름, 설명, 사이트 URL, 로고 이미지, 저작권 문구를 수정할 수 있다.
- **메인 화면**(`home_cover_image_url`, `home_cover_title`, `home_cover_text`): 홈(`/`) 상단 720px 커버 배너. 이미지가 있을 때만 `HomeHero`를 표시하며, 제목·짧은 본문은 이미지 왼쪽 하단 그라데이션 오버레이로 겹친다. 오버레이 본문은 textarea에서 입력한 줄바꿈(`\n`)을 저장·표시하며, `HomeHero` 본문은 `whitespace-pre-line`으로 여러 줄을 렌더링한다. 관리자 UI에서는 커버 파일 업로드·제목·본문을 편집한 뒤 **저장** 한 번에 `PUT /admin/api/settings`로 반영한다. 읽기·편집 미리보기는 실제 `HomeHero` 컴포넌트를 사용해 긴 본문도 공개 화면과 같은 오버레이 폭(`max-w-[32rem]`)과 줄바꿈으로 확인한다. 파일 업로드 API는 디스크에만 올리고 URL을 돌려준다(가로 720px WebP, `/uploads/system/home-cover-YYYYMM-random.webp`).
- **POST 설정**(`show_post_updated_at`): 발행 후 본문·메타 수정이 있을 때 관리자 글 목록·공개 글 상세에 수정 시각 보조 줄을 표시할지 여부.
- **POST 설정**(`show_post_updated_at`): 발행 후 본문·메타 수정이 있을 때 관리자 글 목록에 수정 시각 보조 줄을 표시할지 여부. 공개 게시글 상세에는 수정 시각을 표시하지 않는다.
- **가입 금지 닉네임**(`signup_blocked_usernames`, JSON 문자열): 회원가입·회원 프로필 닉네임 변경 시 닉네임에 목록 단어가 포함되면 거부한다(대소문자 무시, 부분 일치). 안내 문구는 `{단어}은 사용할 수 없는 단어입니다.` 형식이다. 기본값: `admin`, `master`, `zenn`, `sori`, `sori.studio`.
- **어나운스 바**(`announcement_enabled`, `announcement_text`, `announcement_url`, `announcement_background_color`): `announcement_enabled`가 true이고 문구가 비어 있지 않으면 공개 레이아웃(`default`·`post`) 헤더 위에 전체 너비 배너를 표시한다. `announcement_url`이 있으면 문구를 링크로 감싼다(내부 `/…` 또는 `http(s)://`). 배경색은 `#15171a`·`#ffffff`·`#ff4f2e` 중 하나. 방문자는 **이번 방문 동안 닫기**(X, `sessionStorage`) 또는 **7일간 보지 않기**(`localStorage`, 만료 시각 저장)를 선택할 수 있다. 공지 내용이 바뀌어 `updatedAt`이 달라지면 다시 노출된다.
- 로고 이미지는 1:1 비율로 저장하며 `/admin/api/settings/logo` 업로드 시 `/uploads/system/logo-YYYYMM-random.webp``/uploads/system/favicon-YYYYMM-random.png`를 고유 파일명으로 함께 생성한다. 같은 URL 덮어쓰기로 인한 브라우저·운영 정적 캐시 문제를 피하기 위해 로고 교체마다 새 URL을 저장한다.
@@ -679,6 +682,13 @@ components/content/
- 관리자 세션 토큰은 `ADMIN_PASSWORD` 기반 HMAC 서명으로 검증한다.
- `/admin/api/auth/login`, `/admin/api/auth/logout`을 제외한 관리자 API 요청은 서버 미들웨어에서 세션 사용자 ID의 현재 DB 권한이 `owner` 또는 `admin`인지 다시 확인한다. 권한 변경이나 계정 삭제 이후 남아 있는 기존 관리자 쿠키는 이 단계에서 차단한다.
### 게시물 작성자
- `posts.author_id`는 게시물을 만든 회원 ID이며 `users.id`를 참조한다(`ON DELETE SET NULL`).
- 관리자 게시물 생성 시 현재 관리자 세션의 `userId``author_id`로 저장한다.
- 기존 게시물은 마이그레이션 `032_add_post_author.sql`에서 owner/admin 계정이 정확히 1개일 때만 해당 계정으로 `author_id`를 채운다. 여러 관리자 계정이 있으면 임의 배정을 피하기 위해 자동 backfill하지 않는다.
- 공개 게시글 상세의 편집 아이콘 노출은 관리자 여부가 아니라 현재 로그인 회원 ID와 `posts.author_id` 일치 여부를 기준으로 한다.
### 회원 인증
- 회원 인증은 `users` 테이블의 이메일/비밀번호(bcrypt 해시) 기반으로 처리한다.

View File

@@ -1,5 +1,17 @@
# 업데이트 이력
## v1.4.5
- 공개 게시글 상세: 게시물 메타 영역의 수정 시각 표시 제거, 글쓴이용 편집 링크는 요청 SVG 아이콘으로 교체.
- 공개 목록: 홈 Latest·게시물 목록·태그 목록에서 요약이 비어 있으면 본문에서 짧은 설명을 생성해 표시.
- 공개 목록: 목록용 설명은 수동 말줄임을 붙이지 않고 전용 `post-summary-clamp` 2줄/3줄 클래스가 실제 표시 줄 끝에서 처리하도록 보정.
- `composables/createPostSummary.js`: 게시물 요약·본문 fallback 공통 유틸 추가, 빈 단락 마커 HTML 주석은 요약에서 제거.
- 게시물 작성자: `posts.author_id` 마이그레이션 추가 및 owner/admin 단일 계정 환경에서만 기존 글 작성자 backfill.
- 관리자 게시물 저장: 새 글 작성 시 현재 관리자 세션 사용자 ID를 작성자로 저장하고, 기존 글 작성자가 비어 있으면 수정자 ID로 보정.
- 공개 게시글 상세: 관리자 여부가 아니라 로그인 회원 ID와 게시물 작성자 ID가 같을 때만 새 탭 편집 링크 표시.
- 공개 게시글 상세: 요약이 비어 있으면 본문에서 짧은 텍스트를 만들어 SEO·공유 설명에 사용.
- 회원 세션 API: 공개 화면 권한 분기를 위해 `isAdmin`, `role` 응답 필드 추가.
## v1.4.3
- `HomeHero`: 오버레이 본문 줄바꿈(`\n`)이 미리보기·홈에서도 보이도록 `whitespace-pre-line` 적용.

View File

@@ -88,14 +88,10 @@ const getPostFeedArticleClass = (style) => {
*/
const getPostFeedExcerptClass = (style) => {
if (style === 'list') {
return 'line-clamp-3'
return 'post-summary-clamp post-summary-clamp--three'
}
if (style === 'compact') {
return 'line-clamp-1'
}
return 'line-clamp-2'
return 'post-summary-clamp post-summary-clamp--two'
}
const closePostFeedStyleMenu = () => {
@@ -152,7 +148,10 @@ const mapLatestPost = (post) => {
return {
title: post.title,
excerpt: post.excerpt,
excerpt: createPostSummary(post.excerpt, post.content, {
maxLength: 320,
appendEllipsis: false
}),
featuredImage: post.featuredImage,
tagName: tagMeta.name,
tagColor: tagMeta.color,
@@ -479,7 +478,7 @@ const scrollFeatured = (direction) => {
class="post-feed__content relative min-w-0"
:class="isPostFeedCards ? 'flex flex-col' : 'flex flex-[3] flex-col gap-1.5 md:flex-[4]'"
>
<div class="post-feed__content-inner flex min-h-0 flex-1 flex-col gap-1.5">
<div class="post-feed__content-inner flex min-h-0 flex-1 flex-col gap-1">
<h2 class="max-w-[90%] text-sm font-medium leading-tight">
<NuxtLink :to="post.to" class="flex items-center transition-opacity duration-200 hover:opacity-75">
<span v-if="post.isFeatured" class="post-feed__featured-icon mr-1 inline-flex h-4 w-4 items-center justify-center text-[var(--site-accent)]">
@@ -492,6 +491,7 @@ const scrollFeatured = (direction) => {
</h2>
<p
v-if="post.excerpt"
class="flex-1 text-[0.8rem] leading-tight site-muted text-[#6E6661]"
:class="getPostFeedExcerptClass(postFeedStyle)"
>

View File

@@ -13,9 +13,6 @@ const { data: tags } = await useFetch('/api/tags', {
const { data: posts } = await useFetch('/api/posts', {
default: () => []
})
const { data: siteSettings } = await useFetch('/api/site-settings', {
default: () => ({ showPostUpdatedAt: false })
})
if (!post.value) {
throw createError({
@@ -46,16 +43,19 @@ const primaryTagMeta = computed(() => {
})
const publishedAtLabel = computed(() => formatPostDate(post.value.publishedAt || null))
const updatedAtLabel = computed(() => {
if (!siteSettings.value?.showPostUpdatedAt || !wasPostUpdatedAfterPublish(post.value)) {
return ''
}
return `수정: ${formatPostDateTime(post.value.updatedAt)}`
})
const authorLabel = computed(() => 'sori.studio')
const shareModalOpen = ref(false)
const copyButtonLabel = ref('Copy link')
const currentMember = ref(null)
const currentAdmin = ref(null)
const canEditPost = computed(() => Boolean(
post.value?.authorId
&& (
currentMember.value?.id === post.value.authorId
|| currentAdmin.value?.userId === post.value.authorId
)
))
const postEditPath = computed(() => `/admin/posts/${post.value.id}`)
const currentIndex = computed(() => posts.value.findIndex((item) => item.slug === post.value.slug))
const previousPost = computed(() => {
@@ -79,11 +79,12 @@ const config = useRuntimeConfig()
const siteUrl = computed(() => String(config.public.siteUrl || '').replace(/\/$/g, ''))
const pageUrl = computed(() => `${siteUrl.value}/post/${post.value.slug}`)
const seoTitle = computed(() => post.value.seoTitle || post.value.title)
const seoDescription = computed(() => post.value.seoDescription || post.value.excerpt || 'sori.studio 개인 블로그')
const ogImage = computed(() => post.value.featuredImage || '')
const postSummary = computed(() => createPostSummary(post.value.excerpt, post.value.content))
const seoDescription = computed(() => post.value.seoDescription || postSummary.value || 'sori.studio 개인 블로그')
const shareMetadata = computed(() => ({
title: post.value.title || 'sori.studio',
description: post.value.excerpt || 'sori.studio 개인 블로그',
description: seoDescription.value,
image: post.value.featuredImage || '',
url: pageUrl.value
}))
@@ -120,6 +121,28 @@ const shareLinks = computed(() => [
}
])
/**
* 현재 로그인 회원·관리자 정보를 불러온다.
* @returns {Promise<void>}
*/
const fetchCurrentViewer = async () => {
try {
currentMember.value = await $fetch('/api/auth/me')
} catch {
currentMember.value = null
}
try {
currentAdmin.value = await $fetch('/admin/api/auth/me')
} catch {
currentAdmin.value = null
}
}
onMounted(() => {
fetchCurrentViewer()
})
/**
* 절대 URL 생성
* @param {string} value - 원본 URL
@@ -244,10 +267,6 @@ useHead(() => ({
{{ publishedAtLabel }}
</time>
<time v-if="updatedAtLabel" :datetime="post.updatedAt" class="admin-post-detail__updated-at">
{{ updatedAtLabel }}
</time>
<a href="#" class="hover:opacity-75">
{{ authorLabel }}
</a>
@@ -272,11 +291,26 @@ useHead(() => ({
</a>
</div>
<button class="absolute right-0 bottom-4 flex cursor-pointer items-center gap-1 hover:opacity-75" type="button" aria-label="Share this post" data-post-share-toggle @click="openShareModal">
<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 d="M13 4v4c-6.575 1.028-9.02 6.788-10 12c-.037.206 5.384-5.962 10-6v4l8-7-8-7z" />
</svg>
</button>
<div class="absolute right-0 bottom-4 flex items-center gap-3">
<NuxtLink
v-if="canEditPost"
class="flex cursor-pointer items-center gap-1 hover:opacity-75"
:to="postEditPath"
target="_blank"
rel="noreferrer"
aria-label="Edit this post"
data-post-edit-link
>
<svg xmlns="http://www.w3.org/2000/svg" height="18" viewBox="0 -960 960 960" width="18" fill="currentColor" aria-hidden="true">
<path d="M200-200h57l391-391-57-57-391 391v57Zm-80 80v-170l528-527q12-11 26.5-17t30.5-6q16 0 31 6t26 18l55 56q12 11 17.5 26t5.5 30q0 16-5.5 30.5T817-647L290-120H120Zm640-584-56-56 56 56Zm-141 85-28-29 57 57-29-28Z" />
</svg>
</NuxtLink>
<button class="flex cursor-pointer items-center gap-1 hover:opacity-75" type="button" aria-label="Share this post" data-post-share-toggle @click="openShareModal">
<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 d="M13 4v4c-6.575 1.028-9.02 6.788-10 12c-.037.206 5.384-5.962 10-6v4l8-7-8-7z" />
</svg>
</button>
</div>
</div>
<figure v-if="post.featuredImage" class="relative mt-2.5 w-full">

View File

@@ -5,7 +5,10 @@ const { data: posts } = await useFetch('/api/posts', {
const postCards = computed(() => posts.value.map((post) => ({
title: post.title,
excerpt: post.excerpt,
excerpt: createPostSummary(post.excerpt, post.content, {
maxLength: 320,
appendEllipsis: false
}),
featuredImage: post.featuredImage,
tag: post.tags?.[0] ? String(post.tags[0]).toUpperCase() : '',
publishedAt: formatPostDate(post.publishedAt),

View File

@@ -16,7 +16,10 @@ const tagPosts = computed(() => posts.value
.filter((post) => post.tags.includes(slug.value))
.map((post) => ({
title: post.title,
excerpt: post.excerpt,
excerpt: createPostSummary(post.excerpt, post.content, {
maxLength: 320,
appendEllipsis: false
}),
featuredImage: post.featuredImage,
tag: tags.value.find((item) => item.slug === (post.tags?.[0] || slug.value))?.name || (post.tags?.[0] || slug.value).toUpperCase(),
tagColor: tags.value.find((item) => item.slug === (post.tags?.[0] || slug.value))?.color || '#4d4d4d',
@@ -72,7 +75,10 @@ const tagPosts = computed(() => posts.value
{{ post.title }}
</NuxtLink>
</h2>
<p class="flex-1 line-clamp-2 text-[0.8rem] leading-tight site-muted">
<p
v-if="post.excerpt"
class="post-summary-clamp post-summary-clamp--two flex-1 text-[0.8rem] leading-tight site-muted"
>
{{ post.excerpt }}
</p>
<div class="flex flex-wrap items-center gap-2 text-xs site-muted sm:gap-1.5">

View File

@@ -4,7 +4,7 @@ import { requireMemberSession } from '../../utils/member-auth'
/**
* 회원 세션 조회 API
* @param {import('h3').H3Event} event - 요청 이벤트
* @returns {Promise<{ id: string, username: string, email: string, avatarUrl: string }>} 회원 정보
* @returns {Promise<{ id: string, username: string, email: string, avatarUrl: string, isAdmin: boolean, role: string }>} 회원 정보
*/
export default defineEventHandler(async (event) => {
const session = requireMemberSession(event)
@@ -15,7 +15,9 @@ export default defineEventHandler(async (event) => {
id: session.userId,
username: '',
email: session.email,
avatarUrl: ''
avatarUrl: '',
isAdmin: false,
role: 'member'
}
}
@@ -23,6 +25,8 @@ export default defineEventHandler(async (event) => {
id: user.id,
username: user.username,
email: user.email,
avatarUrl: user.avatarUrl || ''
avatarUrl: user.avatarUrl || '',
isAdmin: Boolean(user.isAdmin),
role: user.role || 'member'
}
})

View File

@@ -24,6 +24,7 @@ const mapPostRow = (row) => ({
id: row.id,
title: row.title,
slug: row.slug,
authorId: row.author_id || null,
content: row.content,
excerpt: row.excerpt,
featuredImage: row.featured_image,
@@ -308,9 +309,10 @@ export const getAdminPostById = async (id) => {
/**
* 관리자 게시물 생성
* @param {Object} input - 게시물 입력값
* @param {string} authorId - 작성자 회원 ID
* @returns {Promise<Object>} 생성된 게시물
*/
export const createAdminPost = async (input) => {
export const createAdminPost = async (input, authorId) => {
const sql = getPostgresClient()
if (!sql) {
@@ -322,6 +324,7 @@ export const createAdminPost = async (input) => {
INSERT INTO posts (
title,
slug,
author_id,
content,
excerpt,
featured_image,
@@ -337,6 +340,7 @@ export const createAdminPost = async (input) => {
VALUES (
${input.title},
${input.slug},
${authorId || null},
${input.content},
${input.excerpt},
${input.featuredImage},
@@ -364,9 +368,10 @@ export const createAdminPost = async (input) => {
* 관리자 게시물 수정
* @param {string} id - 게시물 ID
* @param {Object} input - 게시물 입력값
* @param {string} editorId - 수정자 회원 ID
* @returns {Promise<Object | null>} 수정된 게시물
*/
export const updateAdminPost = async (id, input) => {
export const updateAdminPost = async (id, input, editorId) => {
const sql = getPostgresClient()
if (!sql) {
@@ -379,6 +384,7 @@ export const updateAdminPost = async (id, input) => {
SET
title = ${input.title},
slug = ${input.slug},
author_id = COALESCE(author_id, ${editorId || null}),
content = ${input.content},
excerpt = ${input.excerpt},
featured_image = ${input.featuredImage},

View File

@@ -9,7 +9,7 @@ import { createAdminPost } from '../../../repositories/content-repository'
* @returns {Promise<Object>} 생성된 게시물
*/
export default defineEventHandler(async (event) => {
requireAdminSession(event)
const adminSession = requireAdminSession(event)
const parsedBody = parseAdminPostInput(await readBody(event))
@@ -21,7 +21,7 @@ export default defineEventHandler(async (event) => {
}
try {
return await createAdminPost(parsedBody.data)
return await createAdminPost(parsedBody.data, adminSession.userId)
} catch (error) {
if (error?.code === '23505') {
throw createError({

View File

@@ -9,7 +9,7 @@ import { updateAdminPost } from '../../../../repositories/content-repository'
* @returns {Promise<Object>} 수정된 게시물
*/
export default defineEventHandler(async (event) => {
requireAdminSession(event)
const adminSession = requireAdminSession(event)
const id = getRouterParam(event, 'id')
const parsedBody = parseAdminPostInput(await readBody(event))
@@ -22,7 +22,7 @@ export default defineEventHandler(async (event) => {
}
try {
const post = await updateAdminPost(id, parsedBody.data)
const post = await updateAdminPost(id, parsedBody.data, adminSession.userId)
if (!post) {
throw createError({