SNS 링크 설정 추가 v1.5.39

This commit is contained in:
2026-06-02 17:06:02 +09:00
parent e3b8087b09
commit 4da1ade2cf
15 changed files with 411 additions and 20 deletions

View File

@@ -1,5 +1,6 @@
<script setup>
import { getExternalFaviconUrl } from '~/lib/external-favicon-url.js'
import { getVisibleSocialLinks } from '~/lib/social-links.js'
const route = useRoute()
const postToc = useState('post-detail-toc', () => [])
@@ -7,21 +8,13 @@ const tocNavRef = ref(null)
const activeTocId = ref('')
let tocScrollFrame = 0
const followLinks = [
{ id: 'facebook', label: 'Facebook', href: 'https://facebook.com', icon: 'facebook' },
{ id: 'x', label: 'X', href: 'https://x.com', icon: 'x' },
{ id: 'github', label: 'Github', href: 'https://github.com', icon: 'github' },
{ id: 'instagram', label: 'Instagram', href: 'https://instagram.com', icon: 'instagram' },
{ id: 'linkedin', label: 'Linkedin', href: 'https://linkedin.com', icon: 'linkedin' },
{ id: 'rss', label: 'RSS', href: '/rss/', icon: 'rss' }
]
const { data: siteSettings } = await useFetch('/api/site-settings', {
default: () => ({
title: 'sori.studio',
description: 'sori.studio 개인 블로그',
logoText: '井',
logoUrl: '',
socialLinks: [],
copyrightText: '©2026 sori.studio'
})
})
@@ -47,6 +40,7 @@ const recommendedSites = computed(() => {
})
const isPostDetailRoute = computed(() => route.path.startsWith('/post/'))
const postTocItems = computed(() => Array.isArray(postToc.value) ? postToc.value : [])
const followLinks = computed(() => getVisibleSocialLinks(siteSettings.value?.socialLinks || []))
/**
* 고정 상단 영역을 고려한 TOC 판정 기준선을 반환한다.
@@ -267,7 +261,7 @@ watch([postTocItems, () => route.fullPath], async () => {
</div>
</div>
<div class="right-sidebar__block site-sidebar-section py-5 pl-5 pr-0">
<div v-if="followLinks.length" class="right-sidebar__block site-sidebar-section py-5 pl-5 pr-0">
<div class="right-sidebar__row flex items-center justify-between">
<p class="right-sidebar__eyebrow text-xs font-semibold uppercase site-muted">
Follow
@@ -279,8 +273,8 @@ watch([postTocItems, () => route.fullPath], async () => {
class="site-interactive p-0.5 hover:opacity-75"
:href="item.href"
:aria-label="item.label"
target="_blank"
rel="noreferrer"
:target="item.external ? '_blank' : undefined"
:rel="item.external ? 'noreferrer' : undefined"
>
<svg
v-if="item.icon === 'facebook'"
@@ -353,7 +347,21 @@ watch([postTocItems, () => route.fullPath], async () => {
<circle cx="4" cy="4" r="2" />
</svg>
<svg
v-else
v-else-if="item.icon === 'youtube'"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
class="h-4 w-4"
>
<path d="M2.5 17a24.1 24.1 0 0 1 0-10 2 2 0 0 1 1.4-1.4 49.6 49.6 0 0 1 16.2 0A2 2 0 0 1 21.5 7a24.1 24.1 0 0 1 0 10 2 2 0 0 1-1.4 1.4 49.6 49.6 0 0 1-16.2 0A2 2 0 0 1 2.5 17" />
<path d="m10 15 5-3-5-3z" />
</svg>
<svg
v-else-if="item.icon === 'rss'"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
@@ -367,6 +375,20 @@ watch([postTocItems, () => route.fullPath], async () => {
<path d="M4 4a16 16 0 0 1 16 16" />
<path d="M4 11a9 9 0 0 1 9 9" />
</svg>
<svg
v-else
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
class="h-4 w-4"
>
<path d="M10 13a5 5 0 0 0 7.54.54l2-2a5 5 0 0 0-7.07-7.07l-1.15 1.15" />
<path d="M14 11a5 5 0 0 0-7.54-.54l-2 2a5 5 0 0 0 7.07 7.07l1.15-1.15" />
</svg>
<span class="sr-only">{{ item.label }}</span>
</a>
</nav>

View File

@@ -0,0 +1,2 @@
ALTER TABLE site_settings
ADD COLUMN IF NOT EXISTS social_links JSONB NOT NULL DEFAULT '[]'::jsonb;

View File

@@ -1,5 +1,10 @@
# 업데이트 요약
## v1.5.39
- 관리자 사이트 설정에서 SNS 링크를 아이콘 프리셋과 주소 목록으로 관리할 수 있게 했다.
- 공개 오른쪽 사이드바 FOLLOW 영역은 등록된 SNS 링크가 있을 때만 표시된다.
## v1.5.38
- 어나운스 바 배경색을 직접 선택·입력할 수 있게 했다.

View File

@@ -1,6 +1,6 @@
# 배포 가이드
> 로컬 기준 v1.5.38에서 `npm run lint`, `npm run build` 검증을 통과했다. NAS 실제 컨테이너 기동과 도메인/프록시 접속 검증은 운영 배포 단계에서 진행한다.
> 로컬 기준 v1.5.39에서 `npm run lint`, `npm run build` 검증을 통과했다. NAS 실제 컨테이너 기동과 도메인/프록시 접속 검증은 운영 배포 단계에서 진행한다.
## 빌드 유형
@@ -68,6 +68,11 @@ docker exec sori-studio-db pg_isready -U sori_studio -d sori_studio
docker exec sori-studio-db psql -U sori_studio -d sori_studio -c 'SELECT count(*) AS posts_count FROM posts;'
```
### v1.5.39 마이그레이션
- `048_site_settings_social_links.sql`: `site_settings``social_links` JSONB 컬럼을 추가한다.
- 적용 후 관리자 사이트 설정의 SNS 정보와 공개 오른쪽 사이드바 FOLLOW 노출이 정상 동작하는지 확인한다.
### v1.5.38 마이그레이션
- `047_site_settings_announcement_alignment.sql`: `site_settings``announcement_alignment` 컬럼을 추가한다.

View File

@@ -1,5 +1,9 @@
# 의사결정 이력
## 2026-06-02 v1.5.39 — SNS 링크는 고정 필드보다 목록형이 맞다
오른쪽 사이드바 FOLLOW 영역은 사이트마다 사용하는 채널이 크게 다르다. Facebook이나 LinkedIn처럼 쓰지 않는 채널을 고정 필드와 기본 아이콘으로 계속 노출하면 실제 운영 화면과 맞지 않으므로, 사이트 설정에는 아이콘 프리셋과 주소의 목록을 저장한다. 주소가 있는 항목만 공개 화면에 렌더링해 FOLLOW 영역 자체가 운영자가 설정한 채널만 반영하도록 했다.
## 2026-06-02 v1.5.38 — 어나운스 바는 운영자가 브랜드 톤에 맞춘다
어나운스 바는 모든 공개 화면 상단에 노출되는 안내 요소라 프리셋 몇 개만으로는 사이트별 브랜드 톤을 맞추기 어렵다. 브랜드 컬러처럼 hex 색상 입력을 허용하되, 텍스트 대비색은 배경 밝기에 따라 자동 계산해 가독성을 유지한다. 문구 정렬은 중앙과 왼쪽 두 가지로 제한해 닫기 버튼이 오른쪽 끝에 남는 기존 구조를 유지하면서도 공지 성격에 맞게 배치할 수 있게 했다.

View File

@@ -37,6 +37,7 @@
| lib/analytics-shared.js | 통계 추적 경로 필터·체류/스크롤 상수(클라이언트·서버 공용) |
| lib/analytics.js | 서버 전용 visitor/session hash(`node:crypto`) |
| lib/analytics-traffic.js | referrer·User-Agent 기반 유입원·디바이스·검색 키워드 축약 분류 |
| lib/social-links.js | 사이트 설정 SNS 링크 아이콘 프리셋·URL 정리·공개 노출 목록 생성 |
## Nuxt 모듈
@@ -70,7 +71,7 @@
| components/site/SiteSearchModal.vue | `Teleport`·전체 화면 딤·Tags/Posts 결과·일치 구간 강조, 열림 시 `html.site-search-open` 스크롤 잠금 |
| components/site/LeftSidebar.vue | 왼쪽 사이드바, `lg+``sticky`+고정 높이+내부 무스크롤바 스크롤, `lg` 미만은 고정 슬라이드 패널, 상단 메뉴는 `SidebarPrimaryNavList`+`provide`로 트리·펼침 상태(`sori-primary-nav-expanded`), Authors 영역은 비공개, 푸터 `footer` 링크는 `flex-wrap`·테마 버튼 `shrink-0`, 태그 카테고리·테마 점은 `site-sidebar-nav-row` 호버 |
| components/site/SidebarPrimaryNavList.vue | 상단 네비: 부모·리프 동일 `before` 막대/호버 원형, 내부 현재 경로 `--site-accent`, 행 `w-full`+`site-sidebar-nav-row` 호버(`#F7F4EF` 라이트), `inject`·`localStorage` 펼침 |
| components/site/RightSidebar.vue | 오른쪽 사이드바, 일반 화면은 공개 `GET /api/navigation``recommended` 카드 목록(대체 텍스트·썸네일 우선, 외부 URL은 Google 파비콘 fallback), 게시글 상세 데스크톱은 Recommended 대신 H1~H3 TOC(모바일 숨김, 스크롤 위치 기반 활성 표시·내부 자동 스크롤), Follow·구독 폼, About 영역은 비공개, `lg+` 스티키 |
| components/site/RightSidebar.vue | 오른쪽 사이드바, 일반 화면은 공개 `GET /api/navigation``recommended` 카드 목록(대체 텍스트·썸네일 우선, 외부 URL은 Google 파비콘 fallback), 게시글 상세 데스크톱은 Recommended 대신 H1~H3 TOC(모바일 숨김, 스크롤 위치 기반 활성 표시·내부 자동 스크롤), 설정 기반 SNS Follow·구독 폼, About 영역은 비공개, `lg+` 스티키 |
| components/site/MainColumn.vue | 메인 화면 중앙, `lg:max-w-[720px]`로 본문 상한 |
| components/site/HomeHero.vue | 홈 상단 720px 커버 배너, 라이트·다크 테마별 이미지 교체, 왼쪽 하단 오버레이 제목·본문 |
| components/site/PostCard.vue | 목록의 게시물 카드, 카드 hover 인터랙션, 태그는 있을 때만 메타에 표시 |
@@ -146,7 +147,7 @@
| pages/admin/tags/index.vue | 태그 관리(메인 태그 드래그 정렬 자동 저장·more vert 메뉴, 일반 태그 배지 more vert 메뉴·검색/정렬, 태그 추가 버튼), 액션 피드백 토스트 |
| pages/admin/tags/new.vue | 태그 생성 |
| pages/admin/tags/[id].vue | 태그 수정 |
| pages/admin/settings/index.vue | 사이트 설정 Ghost형 전체 화면, **POST 설정**(`showPostUpdatedAt` 토글·읽기 모드 비활성 톤), 블로그 제목·설명, **사이트 정보**(로고·URL·저작권), **메인 화면**(라이트·다크 커버 상하 개별 프리뷰·드롭존 업로드·오버레이 텍스트), **어나운스 바**(사용 토글·맞춤 설정·hex 배경색·텍스트 정렬·읽기 모드 비활성 톤), **스팸 필터**(가입 금지 닉네임), **게시물 내보내기** 독립 카드와 펼침형 전체·연도·월·직접 날짜 범위 작업 요청·목표 ZIP 용량·ZIP당 최대 게시물 수, 작업이 있을 때만 표시되는 **최근 내보내기 작업** 별도 카드(준비 완료 배지 숨김·진행 중 진행도·만료일·파일 체크 선택·전체 선택·선택 파일 다운로드·실패 작업 재시도·실패 상세 오류·작업 삭제), **게시물 가져오기** 독립 카드와 펼침형 ZIP 드롭존·적용 버튼·완료 요약·누락 자산 경고 표시, 진행 중 요청 버튼 잠금 |
| pages/admin/settings/index.vue | 사이트 설정 Ghost형 전체 화면, **POST 설정**(`showPostUpdatedAt` 토글·읽기 모드 비활성 톤), 블로그 제목·설명, **사이트 정보**(로고·URL·저작권), **SNS 정보**(아이콘 프리셋+주소 목록형 관리), **메인 화면**(라이트·다크 커버 상하 개별 프리뷰·드롭존 업로드·오버레이 텍스트), **어나운스 바**(사용 토글·맞춤 설정·hex 배경색·텍스트 정렬·읽기 모드 비활성 톤), **스팸 필터**(가입 금지 닉네임), **게시물 내보내기** 독립 카드와 펼침형 전체·연도·월·직접 날짜 범위 작업 요청·목표 ZIP 용량·ZIP당 최대 게시물 수, 작업이 있을 때만 표시되는 **최근 내보내기 작업** 별도 카드(준비 완료 배지 숨김·진행 중 진행도·만료일·파일 체크 선택·전체 선택·선택 파일 다운로드·실패 작업 재시도·실패 상세 오류·작업 삭제), **게시물 가져오기** 독립 카드와 펼침형 ZIP 드롭존·적용 버튼·완료 요약·누락 자산 경고 표시, 진행 중 요청 버튼 잠금 |
| lib/signup-blocked-usernames.js | 가입 금지 닉네임 정리·매칭·안내 문구 |
| pages/admin/members/index.vue | 관리자 멤버 목록(Ghost형 테이블, 글 목록과 같은 테두리형 검색, 조건 필터, 멤버 추가 버튼, 닉네임+이메일, 등급+비활성 상태, 가입일+최근 활동, IP, 댓글 수) |
| pages/admin/members/new.vue | 관리자 멤버 추가(썸네일 URL, 이름, 이메일, 레이블, 관리자 노트) |

View File

@@ -38,6 +38,7 @@
- `Escape` 키는 통합 검색 모달이 열려 있으면 최우선으로 닫고, 그다음 사용자 드롭다운, 이어서 모바일에서만 좌측 슬라이드 메뉴를 닫는다.
- `/` 키는 `INPUT`·`TEXTAREA`·`SELECT`·`contenteditable`에 포커스가 없고 `Ctrl`/`Meta`/`Alt`와 함께 눌리지 않을 때 통합 검색 모달을 연다. 헤더 검색 영역(`md+`) 클릭으로도 동일하게 연다.
- 헤더 우측 사용자 아이콘 버튼은 로그인 상태면 회원 아바타/닉네임과 설정·로그아웃 메뉴를, 비로그인 상태면 Guest·Sign up·Sign in 메뉴를 표시한다. 회원 아바타 이미지가 없거나 비로그인 상태인 경우 사용자 메뉴 버튼에는 사람 아이콘을 표시한다.
- 오른쪽 사이드바의 FOLLOW 영역은 사이트 설정의 SNS 링크 목록을 기준으로 표시한다. 관리자가 아이콘 프리셋과 주소를 등록한 항목만 노출하며, 등록된 항목이 없으면 FOLLOW 영역 자체를 숨긴다.
### 공개 화면 색상

View File

@@ -1,5 +1,12 @@
# 업데이트 이력
## v1.5.39
- 관리자 사이트 설정: 일반 섹션에 SNS 정보 카드 추가.
- 관리자 사이트 설정: SNS 링크를 아이콘 프리셋과 주소 조합의 목록형으로 입력·저장할 수 있도록 추가.
- 공개 오른쪽 사이드바: 사이트 설정에 등록된 SNS 링크만 FOLLOW 영역에 표시하도록 수정.
- DB: `site_settings.social_links` JSONB 컬럼 추가.
## v1.5.38
- 관리자 사이트 설정: 어나운스 바 배경색을 프리셋뿐 아니라 직접 hex 색상으로 선택·입력할 수 있도록 수정.

114
lib/social-links.js Normal file
View File

@@ -0,0 +1,114 @@
/**
* SNS 아이콘 프리셋
* @type {ReadonlyArray<{ icon: string, label: string, placeholder: string }>}
*/
export const SOCIAL_ICON_PRESETS = [
{ icon: 'x', label: 'X', placeholder: 'https://x.com/...' },
{ icon: 'github', label: 'GitHub', placeholder: 'https://github.com/...' },
{ icon: 'instagram', label: 'Instagram', placeholder: 'https://instagram.com/...' },
{ icon: 'youtube', label: 'YouTube', placeholder: 'https://youtube.com/...' },
{ icon: 'facebook', label: 'Facebook', placeholder: 'https://facebook.com/...' },
{ icon: 'linkedin', label: 'LinkedIn', placeholder: 'https://linkedin.com/in/...' },
{ icon: 'rss', label: 'RSS', placeholder: '/rss/' },
{ icon: 'link', label: 'Link', placeholder: 'https://...' }
]
/** @type {number} 최대 SNS 링크 개수 */
export const MAX_SOCIAL_LINK_COUNT = 12
/**
* SNS 아이콘 프리셋을 찾는다.
* @param {unknown} icon - 아이콘 키
* @returns {{ icon: string, label: string, placeholder: string }} 아이콘 프리셋
*/
export const getSocialIconPreset = (icon) => {
const key = String(icon || '').trim().toLowerCase()
return SOCIAL_ICON_PRESETS.find((preset) => preset.icon === key) || SOCIAL_ICON_PRESETS[SOCIAL_ICON_PRESETS.length - 1]
}
/**
* SNS 링크 URL을 정리한다.
* @param {unknown} value - 입력 URL
* @returns {string} 정리된 URL
*/
export const normalizeSocialLinkUrl = (value) => {
const trimmed = String(value || '').trim()
if (!trimmed) {
return ''
}
if (trimmed.startsWith('/')) {
return trimmed
}
if (/^mailto:[^\s@]+@[^\s@]+\.[^\s@]+$/i.test(trimmed)) {
return trimmed
}
try {
const parsed = new URL(trimmed)
if (parsed.protocol === 'http:' || parsed.protocol === 'https:') {
return parsed.toString()
}
} catch {
return ''
}
return ''
}
/**
* SNS 링크 항목을 정리한다.
* @param {unknown} item - 입력 항목
* @param {number} index - 항목 순서
* @returns {{ id: string, icon: string, label: string, url: string }|null} 정리된 항목
*/
export const normalizeSocialLinkItem = (item, index = 0) => {
if (!item || typeof item !== 'object' || Array.isArray(item)) {
return null
}
const preset = getSocialIconPreset(item.icon)
const url = normalizeSocialLinkUrl(item.url)
if (!url) {
return null
}
return {
id: String(item.id || `${preset.icon}-${index + 1}`).trim().slice(0, 80),
icon: preset.icon,
label: String(item.label || preset.label).trim().slice(0, 80),
url
}
}
/**
* SNS 링크 목록을 저장 가능한 형태로 정리한다.
* @param {unknown} value - 입력 목록
* @returns {Array<{ id: string, icon: string, label: string, url: string }>} 정리된 SNS 링크 목록
*/
export const normalizeSocialLinks = (value) => {
const source = Array.isArray(value)
? value
: Object.entries(value && typeof value === 'object' ? value : {}).map(([icon, url]) => ({ icon, url }))
return source
.slice(0, MAX_SOCIAL_LINK_COUNT)
.map(normalizeSocialLinkItem)
.filter(Boolean)
}
/**
* 저장된 SNS 링크 중 실제 노출할 항목만 반환한다.
* @param {unknown} value - SNS 링크 목록
* @returns {Array<{ id: string, label: string, href: string, icon: string, external: boolean }>} 노출 링크
*/
export const getVisibleSocialLinks = (value) => normalizeSocialLinks(value).map((item) => ({
id: item.id,
label: item.label,
href: item.url,
icon: item.icon,
external: !item.url.startsWith('/') && !item.url.startsWith('mailto:')
}))

4
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "sori.studio",
"version": "1.5.38",
"version": "1.5.39",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "sori.studio",
"version": "1.5.38",
"version": "1.5.39",
"hasInstallScript": true,
"dependencies": {
"@nuxtjs/tailwindcss": "^6.14.0",

View File

@@ -1,6 +1,6 @@
{
"name": "sori.studio",
"version": "1.5.38",
"version": "1.5.39",
"private": true,
"type": "module",
"imports": {

View File

@@ -12,6 +12,11 @@ import {
normalizeSignupBlockedUsernames,
parseSignupBlockedUsernamesFromText
} from '~/lib/signup-blocked-usernames.js'
import {
SOCIAL_ICON_PRESETS,
getSocialIconPreset,
normalizeSocialLinks
} from '~/lib/social-links.js'
definePageMeta({
layout: 'admin'
@@ -21,6 +26,7 @@ const router = useRouter()
const savingTitleDesc = ref(false)
const savingMisc = ref(false)
const savingSocial = ref(false)
const savingPost = ref(false)
const savingHomeCover = ref(false)
const savingBrand = ref(false)
@@ -61,6 +67,8 @@ const scrollSpySuspended = ref(false)
const editTitleDesc = ref(false)
/** 사이트 정보 카드 편집 모드 여부 */
const editMisc = ref(false)
/** SNS 정보 카드 편집 모드 여부 */
const editSocial = ref(false)
/** POST 설정 카드 편집 모드 여부 */
const editPost = ref(false)
/** 메인 화면 커버 카드 편집 모드 여부 */
@@ -86,6 +94,10 @@ const miscSnapshot = reactive({
faviconUrl: '',
copyrightText: ''
})
/** 편집 시작 시점의 SNS 정보(취소 시 복원용) */
const socialSnapshot = reactive({
socialLinks: []
})
/** 편집 시작 시점의 POST 설정(취소 시 복원용) */
const postSnapshot = reactive({
showPostUpdatedAt: false
@@ -139,6 +151,7 @@ const form = reactive({
logoUrl: settings.value?.logoUrl || '',
faviconUrl: settings.value?.faviconUrl || '',
copyrightText: settings.value?.copyrightText || '©2026 sori.studio',
socialLinks: normalizeSocialLinks(settings.value?.socialLinks || []),
showPostUpdatedAt: Boolean(settings.value?.showPostUpdatedAt),
homeCoverImageUrl: settings.value?.homeCoverImageUrl || '',
homeCoverDarkImageUrl: settings.value?.homeCoverDarkImageUrl || '',
@@ -177,6 +190,13 @@ const hasMiscChanges = computed(() => editMisc.value && (
|| form.copyrightText !== miscSnapshot.copyrightText
))
/**
* SNS 정보 변경 여부
* @returns {boolean} 변경 여부
*/
const hasSocialChanges = computed(() => editSocial.value
&& JSON.stringify(normalizeSocialLinks(form.socialLinks)) !== JSON.stringify(normalizeSocialLinks(socialSnapshot.socialLinks)))
/**
* POST 설정 변경 여부
* @returns {boolean} 변경 여부
@@ -484,7 +504,8 @@ const settingsNavGroups = [
heading: '일반',
items: [
{ id: 'admin-settings-section-title', label: '블로그 제목·설명', keywords: 'title description site name', iconId: 'title-desc' },
{ id: 'admin-settings-section-misc', label: '사이트 정보', keywords: 'logo url copyright favicon site info' }
{ id: 'admin-settings-section-misc', label: '사이트 정보', keywords: 'logo url copyright favicon site info' },
{ id: 'admin-settings-section-social', label: 'SNS 정보', keywords: 'social follow sns x github instagram youtube rss', iconId: 'site-code' }
]
},
{
@@ -1064,6 +1085,7 @@ const buildSiteSettingsPayload = () => ({
logoUrl: form.logoUrl,
faviconUrl: form.faviconUrl,
copyrightText: form.copyrightText,
socialLinks: normalizeSocialLinks(form.socialLinks),
showPostUpdatedAt: Boolean(form.showPostUpdatedAt),
homeCoverImageUrl: form.homeCoverImageUrl || '',
homeCoverDarkImageUrl: form.homeCoverDarkImageUrl || '',
@@ -1198,6 +1220,84 @@ const saveMiscSection = async () => {
}
}
/**
* SNS 정보 편집 모드 진입
* @returns {void}
*/
const beginEditSocial = () => {
socialSnapshot.socialLinks = normalizeSocialLinks(form.socialLinks)
form.socialLinks = normalizeSocialLinks(form.socialLinks)
editSocial.value = true
}
/**
* SNS 정보 편집 취소
* @returns {void}
*/
const cancelEditSocial = () => {
form.socialLinks = normalizeSocialLinks(socialSnapshot.socialLinks)
editSocial.value = false
}
/**
* SNS 링크 항목을 추가한다.
* @returns {void}
*/
const addSocialLink = () => {
form.socialLinks = [
...normalizeSocialLinks(form.socialLinks),
{
id: `social-${Date.now()}`,
icon: SOCIAL_ICON_PRESETS[0].icon,
label: SOCIAL_ICON_PRESETS[0].label,
url: ''
}
]
}
/**
* SNS 링크 항목을 제거한다.
* @param {number} index - 제거할 항목 순서
* @returns {void}
*/
const removeSocialLink = (index) => {
form.socialLinks = form.socialLinks.filter((_, itemIndex) => itemIndex !== index)
}
/**
* SNS 링크 아이콘을 변경한다.
* @param {number} index - 항목 순서
* @param {string} icon - 아이콘 키
* @returns {void}
*/
const updateSocialLinkIcon = (index, icon) => {
const preset = getSocialIconPreset(icon)
form.socialLinks[index].icon = preset.icon
form.socialLinks[index].label = preset.label
}
/**
* SNS 정보를 저장한다.
* @returns {Promise<void>}
*/
const saveSocialSection = async () => {
if (!hasSocialChanges.value) {
return
}
form.socialLinks = normalizeSocialLinks(form.socialLinks)
const ok = await persistSiteSettings({
successToast: 'SNS 정보가 저장되었습니다.',
savingFlag: savingSocial
})
if (ok) {
socialSnapshot.socialLinks = normalizeSocialLinks(form.socialLinks)
editSocial.value = false
}
}
/**
* POST 설정 편집 모드 진입
* @returns {void}
@@ -2027,6 +2127,127 @@ onBeforeUnmount(() => {
</section>
<section
id="admin-settings-section-social"
class="admin-settings-screen__card rounded-xl border border-[#e6e8eb] bg-white p-6 shadow-[0_1px_2px_rgba(15,23,42,0.04)]"
>
<div class="flex items-start justify-between gap-4">
<div class="min-w-0 flex-1">
<h2 class="text-base font-semibold text-[#15171a] md:text-lg">
SNS 정보
</h2>
<p
v-if="!editSocial"
class="mr-5 mt-1 text-pretty text-sm leading-relaxed text-[#657080]"
>
오른쪽 사이드바 FOLLOW 영역에 표시할 아이콘과 주소를 관리합니다.
</p>
</div>
<div class="-mr-1 mt-[-5px] flex shrink-0 items-center justify-start gap-2">
<template v-if="!editSocial">
<button
class="inline-flex h-7 cursor-pointer items-center justify-center rounded px-3 text-sm font-semibold whitespace-nowrap text-[#15171a] transition hover:bg-[#eceff2] hover:text-black"
type="button"
@click="beginEditSocial"
>
편집
</button>
</template>
<template v-else>
<button
class="inline-flex h-7 cursor-pointer items-center justify-center rounded px-3 text-sm font-semibold whitespace-nowrap text-[#5d6673] transition hover:bg-[#eceff2] hover:text-[#15171a]"
type="button"
:disabled="savingSocial"
@click="cancelEditSocial"
>
취소
</button>
<button
class="inline-flex h-7 cursor-pointer items-center justify-center rounded px-3 text-sm font-semibold whitespace-nowrap text-[#15171a] transition hover:bg-[#eceff2] hover:text-black disabled:opacity-50"
type="button"
:disabled="savingSocial || !hasSocialChanges"
@click="saveSocialSection"
>
{{ savingSocial ? '저장 중' : '저장' }}
</button>
</template>
</div>
</div>
<div
v-if="!editSocial"
class="admin-settings-screen__social-readonly border-t border-[#eceff2] pt-5"
>
<div
v-if="normalizeSocialLinks(form.socialLinks).length"
class="flex flex-wrap gap-2"
>
<span
v-for="item in normalizeSocialLinks(form.socialLinks)"
:key="item.id"
class="inline-flex items-center gap-2 rounded-full border border-[#dce0e5] bg-[#f7f8fa] px-3 py-1.5 text-sm font-semibold text-[#15171a]"
>
<span>{{ item.label }}</span>
<span class="max-w-[220px] truncate text-xs font-medium text-[#657080]">{{ item.url }}</span>
</span>
</div>
<p v-else class="text-sm text-[#657080]">
등록된 SNS 링크가 없습니다. 공개 화면의 FOLLOW 아이콘도 표시되지 않습니다.
</p>
</div>
<div v-else class="admin-settings-screen__social-edit grid gap-4 border-t border-[#eceff2] pt-5">
<div
v-for="(item, index) in form.socialLinks"
:key="item.id || index"
class="grid gap-3 rounded-lg border border-[#edf0f2] bg-[#fafafa] p-3 md:grid-cols-[180px_minmax(0,1fr)_auto] md:items-center"
>
<label class="grid gap-1.5 text-sm">
<span class="font-medium text-[#3f4650]">아이콘</span>
<div class="relative">
<select
class="h-10 w-full appearance-none rounded-md border border-[#dce0e5] bg-white px-3 pr-9 text-[#15171a] outline-none focus:border-[#15171a] focus:ring-1 focus:ring-[#15171a]"
:value="item.icon"
@change="updateSocialLinkIcon(index, $event.target.value)"
>
<option
v-for="preset in SOCIAL_ICON_PRESETS"
:key="preset.icon"
:value="preset.icon"
>
{{ preset.label }}
</option>
</select>
<svg class="pointer-events-none absolute right-3 top-1/2 size-4 -translate-y-1/2 text-[#394047]" xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="m6 9 6 6 6-6" /></svg>
</div>
</label>
<label class="grid gap-1.5 text-sm">
<span class="font-medium text-[#3f4650]">주소</span>
<input
v-model="item.url"
class="h-10 rounded-md border border-[#dce0e5] bg-white px-3 text-[#15171a] outline-none focus:border-[#15171a] focus:ring-1 focus:ring-[#15171a]"
type="text"
:placeholder="getSocialIconPreset(item.icon).placeholder"
>
</label>
<button
class="h-10 rounded-md border border-[#ffd1d1] bg-white px-4 text-sm font-semibold text-[#d73939] transition-colors hover:bg-[#fff5f5]"
type="button"
@click="removeSocialLink(index)"
>
삭제
</button>
</div>
<button
class="inline-flex h-10 w-fit items-center justify-center rounded-md border border-[#dce0e5] bg-white px-4 text-sm font-semibold text-[#15171a] transition-colors hover:bg-[#f3f5f7]"
type="button"
@click="addSocialLink"
>
SNS 링크 추가
</button>
</div>
</section>
<h2 class="admin-settings-screen__section-heading z-20 mb-px pt-10 text-2xl font-bold tracking-tight text-[#15171a]">
게시물
</h2>

View File

@@ -20,6 +20,7 @@ import {
normalizeSignupBlockedUsernames,
parseSignupBlockedUsernamesFromDb
} from '../../lib/signup-blocked-usernames.js'
import { normalizeSocialLinks } from '../../lib/social-links.js'
import { getPostgresClient } from './postgres-client'
/**
@@ -107,6 +108,7 @@ const mapSiteSettingsRow = (row) => ({
logoUrl: row.logo_url || '',
faviconUrl: row.favicon_url || '',
copyrightText: row.copyright_text,
socialLinks: normalizeSocialLinks(row.social_links),
showPostUpdatedAt: Boolean(row.show_post_updated_at),
homeCoverImageUrl: row.home_cover_image_url || '',
homeCoverDarkImageUrl: row.home_cover_dark_image_url || '',
@@ -876,6 +878,7 @@ export const updateSiteSettings = async (input) => {
logo_url,
favicon_url,
copyright_text,
social_links,
show_post_updated_at,
home_cover_image_url,
home_cover_dark_image_url,
@@ -902,6 +905,7 @@ export const updateSiteSettings = async (input) => {
${input.logoUrl || ''},
${input.faviconUrl || ''},
${input.copyrightText},
${JSON.stringify(normalizeSocialLinks(input.socialLinks))},
${input.showPostUpdatedAt ? true : false},
${input.homeCoverImageUrl || ''},
${input.homeCoverDarkImageUrl || ''},
@@ -928,6 +932,7 @@ export const updateSiteSettings = async (input) => {
logo_url = EXCLUDED.logo_url,
favicon_url = EXCLUDED.favicon_url,
copyright_text = EXCLUDED.copyright_text,
social_links = EXCLUDED.social_links,
show_post_updated_at = EXCLUDED.show_post_updated_at,
home_cover_image_url = EXCLUDED.home_cover_image_url,
home_cover_dark_image_url = EXCLUDED.home_cover_dark_image_url,

View File

@@ -18,6 +18,7 @@ import {
MAX_SIGNUP_BLOCKED_USERNAME_LENGTH,
normalizeSignupBlockedUsernames
} from '../../lib/signup-blocked-usernames.js'
import { normalizeSocialLinks } from '../../lib/social-links.js'
export const adminSiteSettingsInputSchema = z.object({
title: z.string().trim().min(1),
@@ -27,6 +28,7 @@ export const adminSiteSettingsInputSchema = z.object({
logoUrl: z.string().trim().max(500).optional().default(''),
faviconUrl: z.string().trim().max(500).optional().default(''),
copyrightText: z.string().trim().min(1),
socialLinks: z.unknown().optional().default([]),
showPostUpdatedAt: z.boolean().optional().default(false),
homeCoverImageUrl: z.string().trim().max(500).optional().default(''),
homeCoverDarkImageUrl: z.string().trim().max(500).optional().default(''),
@@ -71,6 +73,7 @@ export const adminSiteSettingsInputSchema = z.object({
}).transform((data) => ({
...data,
brandColor: normalizeBrandColor(data.brandColor),
socialLinks: normalizeSocialLinks(data.socialLinks),
announcementUrl: normalizeAnnouncementUrl(data.announcementUrl),
announcementBackgroundColor: normalizeAnnouncementBackgroundColor(data.announcementBackgroundColor),
announcementAlignment: normalizeAnnouncementAlignment(data.announcementAlignment),

View File

@@ -21,6 +21,7 @@ export const getDefaultSiteSettings = () => {
logoUrl: '',
faviconUrl: '',
copyrightText: `©${new Date().getFullYear()} ${title}`,
socialLinks: [],
showPostUpdatedAt: false,
homeCoverImageUrl: '',
homeCoverDarkImageUrl: '',