SNS 링크 저장 복구 v1.5.41

This commit is contained in:
2026-06-02 17:47:15 +09:00
parent b3c7f26d10
commit 21d01632be
12 changed files with 44 additions and 24 deletions

View File

@@ -0,0 +1,5 @@
UPDATE site_settings
SET social_links = (social_links #>> '{}')::jsonb
WHERE jsonb_typeof(social_links) = 'string'
AND (social_links #>> '{}') IS NOT NULL
AND (social_links #>> '{}') ~ '^\s*\[';

View File

@@ -1,5 +1,10 @@
# 업데이트 요약 # 업데이트 요약
## v1.5.41
- SNS 링크가 저장 후 사라져 보이던 문제를 수정했다.
- SNS 링크 편집 화면을 아이콘과 주소 중심으로 단순화했다.
## v1.5.40 ## v1.5.40
- SNS 링크 주소 입력 시 `https://`를 생략해도 자동으로 보정된다. - SNS 링크 주소 입력 시 `https://`를 생략해도 자동으로 보정된다.

View File

@@ -1,6 +1,6 @@
# 배포 가이드 # 배포 가이드
> 로컬 기준 v1.5.40에서 `npm run lint`, `npm run build` 검증을 통과했다. NAS 실제 컨테이너 기동과 도메인/프록시 접속 검증은 운영 배포 단계에서 진행한다. > 로컬 기준 v1.5.41에서 `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;' docker exec sori-studio-db psql -U sori_studio -d sori_studio -c 'SELECT count(*) AS posts_count FROM posts;'
``` ```
### v1.5.41 마이그레이션
- `049_fix_social_links_jsonb_string.sql`: 기존에 JSONB 문자열로 잘못 저장된 `site_settings.social_links` 값을 JSONB 배열로 복구한다.
- 적용 후 관리자 사이트 설정의 SNS 정보 저장값이 읽기 모드에서 유지되는지 확인한다.
### v1.5.40 참고 ### v1.5.40 참고
- 추가 DB 마이그레이션은 없다. - 추가 DB 마이그레이션은 없다.

View File

@@ -1,5 +1,9 @@
# 의사결정 이력 # 의사결정 이력
## 2026-06-02 v1.5.41 — SNS 편집은 아이콘과 주소만으로 충분하다
FOLLOW 영역은 아이콘 버튼 목록이므로 운영자가 직접 보는 편집 화면에서도 핵심 입력은 아이콘과 주소다. 이름은 접근성 라벨로 내부에서 프리셋명을 쓰면 충분하고, 별도 입력칸은 레이아웃을 복잡하게 만든다. 저장값은 JSONB 배열이어야 하므로 기존 문자열 저장값은 마이그레이션으로 복구하고, 저장 쿼리도 명시적으로 JSONB 배열로 캐스팅한다.
## 2026-06-02 v1.5.40 — SNS 아이콘은 프리셋과 직접 SVG를 함께 둔다 ## 2026-06-02 v1.5.40 — SNS 아이콘은 프리셋과 직접 SVG를 함께 둔다
SNS 채널은 서비스 유행에 따라 계속 바뀌므로, Facebook·LinkedIn 같은 고정 필드만 두면 운영자가 쓰지 않는 항목과 새로 필요한 항목 사이에서 계속 코드 수정이 필요해진다. 기본 아이콘은 프리셋으로 빠르게 선택하게 두되, 프리셋에 없는 서비스는 관리자가 SVG 아이콘을 직접 붙여 넣어 같은 목록 구조로 저장하도록 했다. URL은 운영자가 `https://`를 생략해도 정상 링크로 보정해 저장 실패처럼 보이는 혼란을 줄인다. SNS 채널은 서비스 유행에 따라 계속 바뀌므로, Facebook·LinkedIn 같은 고정 필드만 두면 운영자가 쓰지 않는 항목과 새로 필요한 항목 사이에서 계속 코드 수정이 필요해진다. 기본 아이콘은 프리셋으로 빠르게 선택하게 두되, 프리셋에 없는 서비스는 관리자가 SVG 아이콘을 직접 붙여 넣어 같은 목록 구조로 저장하도록 했다. URL은 운영자가 `https://`를 생략해도 정상 링크로 보정해 저장 실패처럼 보이는 혼란을 줄인다.

View File

@@ -37,7 +37,7 @@
| lib/analytics-shared.js | 통계 추적 경로 필터·체류/스크롤 상수(클라이언트·서버 공용) | | lib/analytics-shared.js | 통계 추적 경로 필터·체류/스크롤 상수(클라이언트·서버 공용) |
| lib/analytics.js | 서버 전용 visitor/session hash(`node:crypto`) | | lib/analytics.js | 서버 전용 visitor/session hash(`node:crypto`) |
| lib/analytics-traffic.js | referrer·User-Agent 기반 유입원·디바이스·검색 키워드 축약 분류 | | lib/analytics-traffic.js | referrer·User-Agent 기반 유입원·디바이스·검색 키워드 축약 분류 |
| lib/social-links.js | 사이트 설정 SNS 링크 아이콘 프리셋·사용자 SVG 정리·URL 자동 보정·공개 노출 목록 생성 | | lib/social-links.js | 사이트 설정 SNS 링크 아이콘 프리셋·사용자 SVG 정리·URL 자동 보정·레거시 JSON 문자열 복구·공개 노출 목록 생성 |
## Nuxt 모듈 ## Nuxt 모듈

View File

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

View File

@@ -1,5 +1,11 @@
# 업데이트 이력 # 업데이트 이력
## v1.5.41
- 관리자 사이트 설정: SNS 링크가 JSONB 배열이 아닌 JSON 문자열로 저장되어 저장 후 목록이 비어 보이던 문제 수정.
- 관리자 사이트 설정: 기존에 잘못 저장된 SNS 링크 JSON 문자열을 배열로 복구하는 DB 마이그레이션 추가.
- 관리자 사이트 설정: SNS 편집 UI에서 불필요한 이름 입력칸을 제거하고 아이콘·주소 중심 레이아웃으로 정리.
## v1.5.40 ## v1.5.40
- 관리자 사이트 설정: SNS 링크 주소 입력 시 `https://`를 생략해도 자동 보정되도록 수정. - 관리자 사이트 설정: SNS 링크 주소 입력 시 `https://`를 생략해도 자동 보정되도록 수정.

View File

@@ -126,6 +126,14 @@ export const normalizeSocialLinkItem = (item, index = 0) => {
* @returns {Array<{ id: string, icon: string, label: string, url: string, iconSvg: string }>} 정리된 SNS 링크 목록 * @returns {Array<{ id: string, icon: string, label: string, url: string, iconSvg: string }>} 정리된 SNS 링크 목록
*/ */
export const normalizeSocialLinks = (value) => { export const normalizeSocialLinks = (value) => {
if (typeof value === 'string') {
try {
return normalizeSocialLinks(JSON.parse(value))
} catch {
return []
}
}
const source = Array.isArray(value) const source = Array.isArray(value)
? value ? value
: Object.entries(value && typeof value === 'object' ? value : {}).map(([icon, url]) => ({ icon, url })) : Object.entries(value && typeof value === 'object' ? value : {}).map(([icon, url]) => ({ icon, url }))

4
package-lock.json generated
View File

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

View File

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

View File

@@ -1273,12 +1273,8 @@ const removeSocialLink = (index) => {
*/ */
const updateSocialLinkIcon = (index, icon) => { const updateSocialLinkIcon = (index, icon) => {
const preset = getSocialIconPreset(icon) const preset = getSocialIconPreset(icon)
const currentPreset = getSocialIconPreset(form.socialLinks[index].icon)
form.socialLinks[index].icon = preset.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') { if (preset.icon !== 'custom') {
form.socialLinks[index].iconSvg = '' form.socialLinks[index].iconSvg = ''
@@ -2185,7 +2181,7 @@ onBeforeUnmount(() => {
<div <div
v-if="!editSocial" v-if="!editSocial"
class="admin-settings-screen__social-readonly border-t border-[#eceff2] pt-5" class="admin-settings-screen__social-readonly pt-5"
> >
<div <div
v-if="normalizeSocialLinks(form.socialLinks).length" v-if="normalizeSocialLinks(form.socialLinks).length"
@@ -2209,7 +2205,7 @@ onBeforeUnmount(() => {
<div <div
v-for="(item, index) in form.socialLinks" v-for="(item, index) in form.socialLinks"
:key="item.id || index" :key="item.id || index"
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" class="grid gap-3 rounded-lg border border-[#edf0f2] bg-[#fafafa] p-3 md:grid-cols-[220px_minmax(0,1fr)_auto] md:items-start"
> >
<label class="grid gap-1.5 text-sm"> <label class="grid gap-1.5 text-sm">
<span class="font-medium text-[#3f4650]">아이콘</span> <span class="font-medium text-[#3f4650]">아이콘</span>
@@ -2230,15 +2226,6 @@ 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> <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> </div>
</label> </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"> <label class="grid gap-1.5 text-sm">
<span class="font-medium text-[#3f4650]">주소</span> <span class="font-medium text-[#3f4650]">주소</span>
<input <input
@@ -2257,7 +2244,7 @@ onBeforeUnmount(() => {
</button> </button>
<label <label
v-if="item.icon === 'custom'" v-if="item.icon === 'custom'"
class="grid gap-1.5 text-sm md:col-span-4" class="grid gap-1.5 text-sm md:col-span-3"
> >
<span class="font-medium text-[#3f4650]">사용자 SVG 아이콘</span> <span class="font-medium text-[#3f4650]">사용자 SVG 아이콘</span>
<textarea <textarea

View File

@@ -905,7 +905,7 @@ export const updateSiteSettings = async (input) => {
${input.logoUrl || ''}, ${input.logoUrl || ''},
${input.faviconUrl || ''}, ${input.faviconUrl || ''},
${input.copyrightText}, ${input.copyrightText},
${JSON.stringify(normalizeSocialLinks(input.socialLinks))}, ${JSON.stringify(normalizeSocialLinks(input.socialLinks))}::jsonb,
${input.showPostUpdatedAt ? true : false}, ${input.showPostUpdatedAt ? true : false},
${input.homeCoverImageUrl || ''}, ${input.homeCoverImageUrl || ''},
${input.homeCoverDarkImageUrl || ''}, ${input.homeCoverDarkImageUrl || ''},