diff --git a/assets/css/main.css b/assets/css/main.css index 878ef3e..dda1e07 100644 --- a/assets/css/main.css +++ b/assets/css/main.css @@ -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); diff --git a/components/site/PostCard.vue b/components/site/PostCard.vue index f33af94..5ca421c 100644 --- a/components/site/PostCard.vue +++ b/components/site/PostCard.vue @@ -23,7 +23,10 @@ defineProps({ {{ post.title }} -

+

{{ post.excerpt }}

diff --git a/composables/createPostSummary.js b/composables/createPostSummary.js new file mode 100644 index 0000000..3c798e6 --- /dev/null +++ b/composables/createPostSummary.js @@ -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(//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()}...` +} diff --git a/db/migrations/032_add_post_author.sql b/db/migrations/032_add_post_author.sql new file mode 100644 index 0000000..5c084a9 --- /dev/null +++ b/db/migrations/032_add_post_author.sql @@ -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); diff --git a/docs/history.md b/docs/history.md index 6452df3..c2f9712 100644 --- a/docs/history.md +++ b/docs/history.md @@ -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`가 붙은 일반 관리자 화면에만 적용한다. diff --git a/docs/map.md b/docs/map.md index 2377132..e9ac0c4 100644 --- a/docs/map.md +++ b/docs/map.md @@ -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 | ## 설정/배포 diff --git a/docs/spec.md b/docs/spec.md index f76c38e..fa0506f 100644 --- a/docs/spec.md +++ b/docs/spec.md @@ -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일 때만 관리자 글 목록에 「수정: …」를 노출한다. 공개 게시글 상세에는 수정 시각을 표시하지 않는다. - `

+

@@ -492,6 +491,7 @@ const scrollFeatured = (direction) => {

diff --git a/pages/post/[slug].vue b/pages/post/[slug].vue index 043b5fc..a3a98cd 100644 --- a/pages/post/[slug].vue +++ b/pages/post/[slug].vue @@ -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} + */ +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 }} - - {{ authorLabel }} @@ -272,11 +291,26 @@ useHead(() => ({

- +
+ + + + +
diff --git a/pages/posts/index.vue b/pages/posts/index.vue index 4ea464f..6094b41 100644 --- a/pages/posts/index.vue +++ b/pages/posts/index.vue @@ -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), diff --git a/pages/tag/[slug].vue b/pages/tag/[slug].vue index 1e132a1..bbdf03b 100644 --- a/pages/tag/[slug].vue +++ b/pages/tag/[slug].vue @@ -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 }} -

+

{{ post.excerpt }}

diff --git a/server/api/auth/me.get.js b/server/api/auth/me.get.js index 98ceb29..2e840c0 100644 --- a/server/api/auth/me.get.js +++ b/server/api/auth/me.get.js @@ -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' } }) diff --git a/server/repositories/content-repository.js b/server/repositories/content-repository.js index 455f6fe..f04c487 100644 --- a/server/repositories/content-repository.js +++ b/server/repositories/content-repository.js @@ -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} 생성된 게시물 */ -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} 수정된 게시물 */ -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}, diff --git a/server/routes/admin/api/posts.post.js b/server/routes/admin/api/posts.post.js index 426451c..d6fd5c3 100644 --- a/server/routes/admin/api/posts.post.js +++ b/server/routes/admin/api/posts.post.js @@ -9,7 +9,7 @@ import { createAdminPost } from '../../../repositories/content-repository' * @returns {Promise} 생성된 게시물 */ 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({ diff --git a/server/routes/admin/api/posts/[id].put.js b/server/routes/admin/api/posts/[id].put.js index 4849806..32c5737 100644 --- a/server/routes/admin/api/posts/[id].put.js +++ b/server/routes/admin/api/posts/[id].put.js @@ -9,7 +9,7 @@ import { updateAdminPost } from '../../../../repositories/content-repository' * @returns {Promise} 수정된 게시물 */ 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({