SNS 아이콘 직접 설정 개선 v1.5.40

This commit is contained in:
2026-06-02 17:22:38 +09:00
parent 4da1ade2cf
commit b3c7f26d10
11 changed files with 113 additions and 16 deletions

View File

@@ -375,6 +375,12 @@ 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>
<span
v-else-if="item.icon === 'custom' && item.iconSvg"
class="right-sidebar__custom-social-icon inline-flex h-4 w-4 items-center justify-center [&_svg]:h-4 [&_svg]:w-4"
aria-hidden="true"
v-html="item.iconSvg"
/>
<svg
v-else
xmlns="http://www.w3.org/2000/svg"

View File

@@ -1,5 +1,10 @@
# 업데이트 요약
## v1.5.40
- SNS 링크 주소 입력 시 `https://`를 생략해도 자동으로 보정된다.
- SNS 아이콘 프리셋에 없는 서비스는 직접 SVG 아이콘을 등록해 사용할 수 있게 했다.
## v1.5.39
- 관리자 사이트 설정에서 SNS 링크를 아이콘 프리셋과 주소 목록으로 관리할 수 있게 했다.

View File

@@ -1,6 +1,6 @@
# 배포 가이드
> 로컬 기준 v1.5.39에서 `npm run lint`, `npm run build` 검증을 통과했다. NAS 실제 컨테이너 기동과 도메인/프록시 접속 검증은 운영 배포 단계에서 진행한다.
> 로컬 기준 v1.5.40에서 `npm run lint`, `npm run build` 검증을 통과했다. NAS 실제 컨테이너 기동과 도메인/프록시 접속 검증은 운영 배포 단계에서 진행한다.
## 빌드 유형
@@ -68,6 +68,12 @@ 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.40 참고
- 추가 DB 마이그레이션은 없다.
- 관리자 사이트 설정의 SNS 정보에서 프리셋이 없는 서비스는 `직접 SVG`를 선택해 SVG 아이콘과 주소를 함께 저장한다.
- `https://`를 생략한 SNS 주소는 저장 시 자동 보정된다.
### v1.5.39 마이그레이션
- `048_site_settings_social_links.sql`: `site_settings``social_links` JSONB 컬럼을 추가한다.

View File

@@ -1,5 +1,9 @@
# 의사결정 이력
## 2026-06-02 v1.5.40 — SNS 아이콘은 프리셋과 직접 SVG를 함께 둔다
SNS 채널은 서비스 유행에 따라 계속 바뀌므로, Facebook·LinkedIn 같은 고정 필드만 두면 운영자가 쓰지 않는 항목과 새로 필요한 항목 사이에서 계속 코드 수정이 필요해진다. 기본 아이콘은 프리셋으로 빠르게 선택하게 두되, 프리셋에 없는 서비스는 관리자가 SVG 아이콘을 직접 붙여 넣어 같은 목록 구조로 저장하도록 했다. URL은 운영자가 `https://`를 생략해도 정상 링크로 보정해 저장 실패처럼 보이는 혼란을 줄인다.
## 2026-06-02 v1.5.39 — SNS 링크는 고정 필드보다 목록형이 맞다
오른쪽 사이드바 FOLLOW 영역은 사이트마다 사용하는 채널이 크게 다르다. Facebook이나 LinkedIn처럼 쓰지 않는 채널을 고정 필드와 기본 아이콘으로 계속 노출하면 실제 운영 화면과 맞지 않으므로, 사이트 설정에는 아이콘 프리셋과 주소의 목록을 저장한다. 주소가 있는 항목만 공개 화면에 렌더링해 FOLLOW 영역 자체가 운영자가 설정한 채널만 반영하도록 했다.

View File

@@ -37,7 +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 정리·공개 노출 목록 생성 |
| lib/social-links.js | 사이트 설정 SNS 링크 아이콘 프리셋·사용자 SVG 정리·URL 자동 보정·공개 노출 목록 생성 |
## Nuxt 모듈
@@ -71,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(모바일 숨김, 스크롤 위치 기반 활성 표시·내부 자동 스크롤), 설정 기반 SNS Follow·구독 폼, About 영역은 비공개, `lg+` 스티키 |
| components/site/RightSidebar.vue | 오른쪽 사이드바, 일반 화면은 공개 `GET /api/navigation``recommended` 카드 목록(대체 텍스트·썸네일 우선, 외부 URL은 Google 파비콘 fallback), 게시글 상세 데스크톱은 Recommended 대신 H1~H3 TOC(모바일 숨김, 스크롤 위치 기반 활성 표시·내부 자동 스크롤), 설정 기반 SNS Follow(프리셋·사용자 SVG)·구독 폼, About 영역은 비공개, `lg+` 스티키 |
| components/site/MainColumn.vue | 메인 화면 중앙, `lg:max-w-[720px]`로 본문 상한 |
| components/site/HomeHero.vue | 홈 상단 720px 커버 배너, 라이트·다크 테마별 이미지 교체, 왼쪽 하단 오버레이 제목·본문 |
| components/site/PostCard.vue | 목록의 게시물 카드, 카드 hover 인터랙션, 태그는 있을 때만 메타에 표시 |

View File

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

View File

@@ -1,5 +1,12 @@
# 업데이트 이력
## v1.5.40
- 관리자 사이트 설정: SNS 링크 주소 입력 시 `https://`를 생략해도 자동 보정되도록 수정.
- 관리자 사이트 설정: SNS 아이콘 프리셋에 `직접 SVG` 옵션을 추가해 새 SNS 아이콘을 코드 수정 없이 등록할 수 있도록 수정.
- 관리자 사이트 설정: SNS 링크 편집에서 표시 이름과 사용자 SVG 아이콘 입력을 지원하도록 추가.
- 공개 오른쪽 사이드바: 사용자 지정 SVG SNS 아이콘을 FOLLOW 영역에 표시하도록 수정.
## v1.5.39
- 관리자 사이트 설정: 일반 섹션에 SNS 정보 카드 추가.

View File

@@ -10,6 +10,7 @@ export const SOCIAL_ICON_PRESETS = [
{ icon: 'facebook', label: 'Facebook', placeholder: 'https://facebook.com/...' },
{ icon: 'linkedin', label: 'LinkedIn', placeholder: 'https://linkedin.com/in/...' },
{ icon: 'rss', label: 'RSS', placeholder: '/rss/' },
{ icon: 'custom', label: '직접 SVG', placeholder: 'https://...' },
{ icon: 'link', label: 'Link', placeholder: 'https://...' }
]
@@ -46,8 +47,12 @@ export const normalizeSocialLinkUrl = (value) => {
return trimmed
}
const normalized = trimmed.startsWith('//')
? `https:${trimmed}`
: (/^[a-z][a-z0-9+.-]*:/i.test(trimmed) ? trimmed : `https://${trimmed}`)
try {
const parsed = new URL(trimmed)
const parsed = new URL(normalized)
if (parsed.protocol === 'http:' || parsed.protocol === 'https:') {
return parsed.toString()
}
@@ -58,11 +63,38 @@ export const normalizeSocialLinkUrl = (value) => {
return ''
}
/**
* 사용자 지정 SNS SVG 아이콘을 정리한다.
* @param {unknown} value - SVG 입력값
* @returns {string} 정리된 SVG
*/
export const normalizeSocialIconSvg = (value) => {
const svg = String(value || '').trim().slice(0, 4000)
const lower = svg.toLowerCase()
if (!svg || !lower.startsWith('<svg') || !lower.endsWith('</svg>')) {
return ''
}
if (
lower.includes('<script')
|| lower.includes('<foreignobject')
|| lower.includes('<iframe')
|| lower.includes('<object')
|| lower.includes('javascript:')
|| /\son[a-z]+\s*=/i.test(svg)
) {
return ''
}
return svg
}
/**
* SNS 링크 항목을 정리한다.
* @param {unknown} item - 입력 항목
* @param {number} index - 항목 순서
* @returns {{ id: string, icon: string, label: string, url: string }|null} 정리된 항목
* @returns {{ id: string, icon: string, label: string, url: string, iconSvg: string }|null} 정리된 항목
*/
export const normalizeSocialLinkItem = (item, index = 0) => {
if (!item || typeof item !== 'object' || Array.isArray(item)) {
@@ -71,6 +103,9 @@ export const normalizeSocialLinkItem = (item, index = 0) => {
const preset = getSocialIconPreset(item.icon)
const url = normalizeSocialLinkUrl(item.url)
const iconSvg = preset.icon === 'custom'
? normalizeSocialIconSvg(item.iconSvg || item.customIconSvg)
: ''
if (!url) {
return null
@@ -80,14 +115,15 @@ export const normalizeSocialLinkItem = (item, index = 0) => {
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
url,
iconSvg
}
}
/**
* SNS 링크 목록을 저장 가능한 형태로 정리한다.
* @param {unknown} value - 입력 목록
* @returns {Array<{ id: string, icon: string, label: string, url: string }>} 정리된 SNS 링크 목록
* @returns {Array<{ id: string, icon: string, label: string, url: string, iconSvg: string }>} 정리된 SNS 링크 목록
*/
export const normalizeSocialLinks = (value) => {
const source = Array.isArray(value)
@@ -103,12 +139,13 @@ export const normalizeSocialLinks = (value) => {
/**
* 저장된 SNS 링크 중 실제 노출할 항목만 반환한다.
* @param {unknown} value - SNS 링크 목록
* @returns {Array<{ id: string, label: string, href: string, icon: string, external: boolean }>} 노출 링크
* @returns {Array<{ id: string, label: string, href: string, icon: string, iconSvg: string, external: boolean }>} 노출 링크
*/
export const getVisibleSocialLinks = (value) => normalizeSocialLinks(value).map((item) => ({
id: item.id,
label: item.label,
href: item.url,
icon: item.icon,
iconSvg: item.iconSvg,
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.39",
"version": "1.5.40",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "sori.studio",
"version": "1.5.39",
"version": "1.5.40",
"hasInstallScript": true,
"dependencies": {
"@nuxtjs/tailwindcss": "^6.14.0",

View File

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

View File

@@ -1250,7 +1250,8 @@ const addSocialLink = () => {
id: `social-${Date.now()}`,
icon: SOCIAL_ICON_PRESETS[0].icon,
label: SOCIAL_ICON_PRESETS[0].label,
url: ''
url: '',
iconSvg: ''
}
]
}
@@ -1272,8 +1273,16 @@ const removeSocialLink = (index) => {
*/
const updateSocialLinkIcon = (index, icon) => {
const preset = getSocialIconPreset(icon)
const currentPreset = getSocialIconPreset(form.socialLinks[index].icon)
form.socialLinks[index].icon = preset.icon
form.socialLinks[index].label = preset.label
if (!form.socialLinks[index].label || form.socialLinks[index].label === currentPreset.label) {
form.socialLinks[index].label = preset.label
}
if (preset.icon !== 'custom') {
form.socialLinks[index].iconSvg = ''
}
}
/**
@@ -2200,7 +2209,7 @@ onBeforeUnmount(() => {
<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"
class="grid gap-3 rounded-lg border border-[#edf0f2] bg-[#fafafa] p-3 md:grid-cols-[170px_minmax(0,0.7fr)_minmax(0,1fr)_auto] md:items-start"
>
<label class="grid gap-1.5 text-sm">
<span class="font-medium text-[#3f4650]">아이콘</span>
@@ -2221,6 +2230,15 @@ onBeforeUnmount(() => {
<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.label"
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="표시 이름"
>
</label>
<label class="grid gap-1.5 text-sm">
<span class="font-medium text-[#3f4650]">주소</span>
<input
@@ -2231,12 +2249,26 @@ onBeforeUnmount(() => {
>
</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]"
class="mt-[26px] 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>
<label
v-if="item.icon === 'custom'"
class="grid gap-1.5 text-sm md:col-span-4"
>
<span class="font-medium text-[#3f4650]">사용자 SVG 아이콘</span>
<textarea
v-model="item.iconSvg"
class="min-h-24 rounded-md border border-[#dce0e5] bg-white px-3 py-2 font-mono text-xs leading-relaxed text-[#15171a] outline-none focus:border-[#15171a] focus:ring-1 focus:ring-[#15171a]"
placeholder="<svg ...>...</svg>"
/>
<span class="text-xs leading-relaxed text-[#8b95a1]">
SNS 아이콘은 SVG 코드를 붙여 넣어 사용할 있습니다. 저장 기본적인 위험 태그는 제거됩니다.
</span>
</label>
</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]"