외부 파비콘을 faviconV2 origin 방식으로 수정해 서브도메인 아이콘이 올바르게 표시되도록 한다.
uniare.sori.studio처럼 domain 기반 API가 기본 지구본만 반환하던 문제를 해결하고, Recommended 썸네일 실패 fallback을 추가한다. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -1,4 +1,6 @@
|
||||
<script setup>
|
||||
import { getExternalFaviconUrl } from '~/lib/external-favicon-url.js'
|
||||
|
||||
const props = defineProps({
|
||||
url: {
|
||||
type: String,
|
||||
@@ -34,13 +36,7 @@ const displayHost = computed(() => {
|
||||
* 썸네일이 비었을 때 파비콘 보조 URL을 만든다.
|
||||
* @returns {string} favicon 요청 URL
|
||||
*/
|
||||
const faviconUrl = computed(() => {
|
||||
if (!displayHost.value) {
|
||||
return ''
|
||||
}
|
||||
|
||||
return `https://www.google.com/s2/favicons?domain=${encodeURIComponent(displayHost.value)}&sz=128`
|
||||
})
|
||||
const faviconUrl = computed(() => getExternalFaviconUrl(props.url, 128))
|
||||
|
||||
/**
|
||||
* 실제로 표시할 이미지 주소
|
||||
|
||||
@@ -55,14 +55,55 @@ const getRecommendedDisplayText = (item) => {
|
||||
return String(item?.descriptionText || '').trim() || String(item?.url || '').trim()
|
||||
}
|
||||
|
||||
/** 추천 사이트 이미지 로드 실패 단계 (`thumbnail` → `favicon` → `letter`) */
|
||||
const recommendedImageStage = ref({})
|
||||
|
||||
/**
|
||||
* 추천 사이트 파비콘 URL을 반환한다.
|
||||
* @param {Object} item - 추천 사이트 항목
|
||||
* @returns {string} 파비콘 URL
|
||||
*/
|
||||
const getRecommendedFaviconUrl = (item) => getExternalFaviconUrl(item?.url, 64)
|
||||
|
||||
/**
|
||||
* 추천 사이트 이미지 URL을 반환한다.
|
||||
* @param {Object} item - 추천 사이트 항목
|
||||
* @returns {string} 이미지 URL
|
||||
*/
|
||||
const getRecommendedImageUrl = (item) => {
|
||||
const id = String(item?.id || '')
|
||||
const stage = recommendedImageStage.value[id] || 'thumbnail'
|
||||
const thumbnailUrl = String(item?.thumbnailUrl || '').trim()
|
||||
return thumbnailUrl || getExternalFaviconUrl(item?.url, 64)
|
||||
const faviconUrl = getRecommendedFaviconUrl(item)
|
||||
|
||||
if (stage === 'letter') {
|
||||
return ''
|
||||
}
|
||||
if (stage === 'favicon') {
|
||||
return faviconUrl
|
||||
}
|
||||
return thumbnailUrl || faviconUrl
|
||||
}
|
||||
|
||||
/**
|
||||
* 추천 사이트 이미지 로드 실패 시 다음 단계로 넘긴다.
|
||||
* @param {Object} item - 추천 사이트 항목
|
||||
* @returns {void}
|
||||
*/
|
||||
const onRecommendedImageError = (item) => {
|
||||
const id = String(item?.id || '')
|
||||
if (!id) {
|
||||
return
|
||||
}
|
||||
const thumbnailUrl = String(item?.thumbnailUrl || '').trim()
|
||||
const faviconUrl = getRecommendedFaviconUrl(item)
|
||||
const stage = recommendedImageStage.value[id] || 'thumbnail'
|
||||
|
||||
if (stage === 'thumbnail' && thumbnailUrl && faviconUrl) {
|
||||
recommendedImageStage.value = { ...recommendedImageStage.value, [id]: 'favicon' }
|
||||
return
|
||||
}
|
||||
recommendedImageStage.value = { ...recommendedImageStage.value, [id]: 'letter' }
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -243,6 +284,7 @@ onMounted(() => {
|
||||
:alt="item.thumbnailUrl ? item.label : ''"
|
||||
loading="lazy"
|
||||
referrerpolicy="no-referrer"
|
||||
@error="onRecommendedImageError(item)"
|
||||
>
|
||||
<span v-else class="px-1 text-center leading-none">{{ (item.label || '?').slice(0, 1) }}</span>
|
||||
</span>
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
# 의사결정 이력
|
||||
|
||||
## 2026-06-17 v1.6.1 — 외부 파비콘은 domain 대신 origin 기반 faviconV2를 쓴다
|
||||
|
||||
추천 사이트·북마크 카드의 파비콘은 Google `s2/favicons?domain=`로 호스트만 넘기고 있었는데, `uniare.sori.studio`처럼 서브도메인·신규 사이트에서는 Google이 실제 파비콘 대신 기본 지구본 PNG만 돌려주는 경우가 확인됐다. 같은 서비스의 `faviconV2`에 `https://` origin을 넘기면 실제 아이콘을 받을 수 있으므로 `lib/external-favicon-url.js`를 V2 URL 생성으로 바꿨다. Google CDN 캐시 특성상 파비콘 변경 반영은 여전히 지연될 수 있어, 확실한 표시가 필요하면 관리자 썸네일 URL 등록을 유지한다.
|
||||
|
||||
## 2026-06-17 v1.5.109 — 추천 사이트는 태그 아래 탐색 영역에 둔다
|
||||
|
||||
추천 사이트 목록은 시간이 지날수록 늘어나는 탐색 보조 정보이므로 오른쪽 사이드바에 두면 광고 슬롯 높이와 게시물 상세 목차 흐름을 예측하기 어려워진다. 추천 사이트를 왼쪽 사이드바의 Categories 아래로 옮겨 태그와 같은 탐색 맥락에 묶고, 오른쪽 사이드바는 일반 화면 광고와 게시물 상세 TOC·광고에 집중하도록 분리한다.
|
||||
|
||||
@@ -26,7 +26,7 @@
|
||||
| 파일 | 용도 |
|
||||
|------|------|
|
||||
| lib/upload-size-limit.js | 관리자 미디어 업로드 종류별 최대 바이트 판별·용량 문구(서버·클라이언트 공용) |
|
||||
| lib/external-favicon-url.js | 외부 URL 호스트 기준 Google `s2/favicons` 썸네일 URL 생성(내부 경로는 빈 문자열) |
|
||||
| lib/external-favicon-url.js | 외부 URL origin 기준 Google `gstatic.com/faviconV2` 파비콘 URL 생성(내부 경로는 빈 문자열) |
|
||||
| lib/navigation-editor-tree.js | 관리자 메뉴 UI·서버 `renumberSortOrderByTree`가 쓰는 `buildNavigationEditorTree`, 관리자 상단 네비 평면 표용 `flattenNavigationEditorWrappers` |
|
||||
| lib/markdown-content-normalizer.js | 관리자 Markdown-first 전환 후 레거시 블록 배열·객체 본문 값을 저장용 마크다운 문자열로 변환 |
|
||||
| lib/markdown-block-context.js | 관리자 Markdown textarea·라이브 편집 포커스 위치 기준 이미지·갤러리·HTML 임베드·인용·콜아웃·코드·토글 블록 설정 패널 대상 판별, 코드 fenced 블록 여닫는 펜스 쌍 판별 |
|
||||
|
||||
@@ -785,7 +785,7 @@ components/content/
|
||||
- `GET /api/navigation` 응답: `primary`는 **트리**(각 노드에 `children` 배열이 있을 수 있음, 리프는 `children` 없음). 노드 필드: `id`, `label`, `url`, `isFolder`, `isVisible`. 트리 조립 시 **동일 `id`는 한 번만 반영**하고, DB 평면 행에 `parent_id`가 있어 자식으로 붙은 항목은 **루트에 두지 않는다**. **상단은 루트 직속 자식만** 트리에 붙이며(부모의 부모가 있는 행은 자식으로 무시), `footer`·`recommended`는 **평면** 배열(`parent_id` 없음)이다. `recommended`는 `id`, `label`, `url`, `descriptionText`, `thumbnailUrl`, `isVisible`을 내려준다.
|
||||
- `PUT /admin/api/navigation` 요청 본문의 각 항목은 반드시 `id`(UUID)를 포함한다. 저장 시 `sort_order`는 `primary`·`footer`·`recommended` 각각 위치별 트리(또는 평면 루트) DFS 순으로 다시 부여한다. `parent_id`는 `primary`에서만 허용되며 **상단은 한 단계(루트→자식)만** 허용한다. `footer`·`recommended` 항목은 항상 루트다. `is_visible`·`is_folder`는 요청값과 무관하게 서버에서 **항상 표시·자식 유무 기준 폴더**로 덮어쓴다.
|
||||
- URL은 `/`로 시작하는 내부 경로, `http(s)://` 외부 URL, 폴더 전용 자리 표시 `#`를 허용한다.
|
||||
- 관리자 메뉴 화면은 **상단 네비게이션**·**하단 네비게이션**·**추천 사이트** 탭으로 구분한다. 편집 UI는 **태그 관리 메인 태그**와 같은 테이블·`cursor-move` 행 드래그 패턴을 쓰며, 드래그는 입력·버튼 위가 아닌 행 여백·개요 열에서 시작한다. 상단은 `buildNavigationEditorTree` 결과를 `flattenNavigationEditorWrappers`로 한 테이블에 평면 표시하고, **같은 행의 위 1/3·가운데·아래 1/3**에 드롭하면 각각 대상 **앞**(동일 부모)·**하위**(대상의 자식, **루트 행에만**)·**뒤**(동일 부모)로 이동한다. **상단은 루트→자식 한 단계만** 허용하며(하위 행에는 가운데 드롭이 형제 앞·뒤로 대체됨, 이미 하위가 있는 항목은 다른 항목의 하위로 넣을 수 없음), 드래그 중 하이라이트는 **형제 앞·뒤**를 파란 가로 끝선·연한 파란 배경, **하위 편입**을 앰버 링·연한 앰버 배경으로 구분하고, 개요 열에 `앞에 끼움` / `뒤에 끼움` / `하위로 넣기` 짧은 문구를 덧붙인다. 개요 열 표기는 `1`, `2.1`, `2.2`, `3`처럼 깊이별 계층 번호로 보이며, 라벨 열은 깊이만큼 `padding-left`로 들여쓴다. 자기 자신·자기 하위로의 편입은 거부한다. 하단·추천 사이트는 평면 목록만 드래그 정렬한다. 추천 사이트는 공개 화면 **왼쪽 사이드바** Categories 아래 Recommended 영역에 카드로 노출되며, 대체 텍스트가 있으면 URL 대신 대체 텍스트를 표시하고 썸네일 URL이 있으면 Google Favicon 프록시 대신 썸네일을 표시한다. 썸네일이 없고 `https://` URL이면 클라이언트에서 호스트를 뽑아 Google Favicon 프록시(`https://www.google.com/s2/favicons?domain=…`) 이미지로 표시할 수 있다(내부 경로만 있으면 아이콘 생략). 오른쪽 사이드바는 추천 사이트를 렌더하지 않고 광고와 게시물 목차 영역에 집중한다.
|
||||
- 관리자 메뉴 화면은 **상단 네비게이션**·**하단 네비게이션**·**추천 사이트** 탭으로 구분한다. 편집 UI는 **태그 관리 메인 태그**와 같은 테이블·`cursor-move` 행 드래그 패턴을 쓰며, 드래그는 입력·버튼 위가 아닌 행 여백·개요 열에서 시작한다. 상단은 `buildNavigationEditorTree` 결과를 `flattenNavigationEditorWrappers`로 한 테이블에 평면 표시하고, **같은 행의 위 1/3·가운데·아래 1/3**에 드롭하면 각각 대상 **앞**(동일 부모)·**하위**(대상의 자식, **루트 행에만**)·**뒤**(동일 부모)로 이동한다. **상단은 루트→자식 한 단계만** 허용하며(하위 행에는 가운데 드롭이 형제 앞·뒤로 대체됨, 이미 하위가 있는 항목은 다른 항목의 하위로 넣을 수 없음), 드래그 중 하이라이트는 **형제 앞·뒤**를 파란 가로 끝선·연한 파란 배경, **하위 편입**을 앰버 링·연한 앰버 배경으로 구분하고, 개요 열에 `앞에 끼움` / `뒤에 끼움` / `하위로 넣기` 짧은 문구를 덧붙인다. 개요 열 표기는 `1`, `2.1`, `2.2`, `3`처럼 깊이별 계층 번호로 보이며, 라벨 열은 깊이만큼 `padding-left`로 들여쓴다. 자기 자신·자기 하위로의 편입은 거부한다. 하단·추천 사이트는 평면 목록만 드래그 정렬한다. 추천 사이트는 공개 화면 **왼쪽 사이드바** Categories 아래 Recommended 영역에 카드로 노출되며, 대체 텍스트가 있으면 URL 대신 대체 텍스트를 표시하고 썸네일 URL이 있으면 Google Favicon 프록시 대신 썸네일을 표시한다. 썸네일이 없고 `https://` URL이면 클라이언트에서 origin을 뽑아 Google Favicon V2(`https://t3.gstatic.com/faviconV2?...`) 이미지로 표시할 수 있다(내부 경로만 있으면 아이콘 생략). 썸네일 로드에 실패하면 파비콘, 파비콘도 실패하면 라벨 첫 글자로 대체한다. 오른쪽 사이드바는 추천 사이트를 렌더하지 않고 광고와 게시물 목차 영역에 집중한다.
|
||||
- `parent_id` / `is_folder` 컬럼이 DB에 없을 때 저장은 실패한다. `npm run db:migrate:dev`로 `017_navigation_hierarchy.sql`을 적용해야 한다(저장 API는 해당 경우 한국어 안내 메시지를 반환할 수 있다).
|
||||
- 공개 왼쪽 사이드바 상단은 `SidebarPrimaryNavList`로 렌더링한다. **하위가 있는 노드**는 한 줄 `button`으로, **행 전체(이름·왼쪽 세로 데코·chevron) 클릭**으로 접기/펼친다. 부모·리프 모두 왼쪽 장식은 **기본 세로 막대(`--site-line`)**, **호버 시에만** 리프와 동일하게 **작은 원형**으로 전환한다. **내부 경로**(`/`로 시작, `//`·`http(s)` 제외)이고 현재 `route.path`와 정규화한 경로가 같으면 장식 색을 **`--site-accent`(브랜드 오렌지)**로 둔다. 외부 URL은 비교하지 않는다. 펼침 상태는 `localStorage` 키 `sori-primary-nav-expanded`에 저장된다. 상단·리프 링크·부모 버튼 행은 **`w-full`**로 `site-sidebar-nav-row` 호버 배경이 가로 전체를 쓴다(라이트 `#F7F4EF`, 다크는 `site-panel-hover`와 동일한 `color-mix` 패턴).
|
||||
- 관리자 메뉴 관리 화면에서 저장 API로 보내는 직렬화 결과가 서버에서 불러온 직후와 동일하면 `메뉴 저장` 버튼이 비활성화된다.
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
# 업데이트 이력
|
||||
|
||||
## v1.6.1
|
||||
|
||||
- 외부 파비콘 URL 생성: Google `s2/favicons?domain=` 대신 `faviconV2`+origin URL을 사용해 서브도메인·신규 사이트에서 기본 지구본만 나오던 문제 수정.
|
||||
- 공개 왼쪽 사이드바 Recommended: 썸네일 로드 실패 시 파비콘·첫 글자 순으로 fallback.
|
||||
- 본문 북마크 카드: 파비콘 URL 생성을 `lib/external-favicon-url.js` 공통 유틸로 통일.
|
||||
|
||||
## v1.6.0
|
||||
|
||||
- 미디어 관리: 카드 썸네일·프로필 이미지 탭의 목록 카드 오른쪽 위에도 실제 사용 개수 배지를 표시하도록 수정.
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
/**
|
||||
* 외부 사이트 파비콘을 브라우저에 표시하기 위한 프록시 URL을 만든다.
|
||||
* 호스트만 추출해 Google Favicon 서비스(`https://www.google.com/s2/favicons`) URL을 반환한다.
|
||||
* Google Favicon V2(`gstatic.com/faviconV2`)에 페이지 origin을 넘겨 파비콘을 받는다.
|
||||
* `s2/favicons?domain=`만 쓰면 일부 서브도메인·신규 사이트에서 기본 지구본만 돌아올 수 있다.
|
||||
* 내부 경로(`/…`)·`#`·파싱 실패 시 빈 문자열을 반환한다(이미지 생략).
|
||||
* @param {string} rawUrl - 링크 URL
|
||||
* @param {number} [sizePx] - 한 변(px), 기본 32, 최대 128
|
||||
* @returns {string} `https://www.google.com/s2/favicons?...` 또는 `''`
|
||||
* @returns {string} `https://t3.gstatic.com/faviconV2?...` 또는 `''`
|
||||
*/
|
||||
export const getExternalFaviconUrl = (rawUrl, sizePx = 32) => {
|
||||
const trimmed = String(rawUrl || '').trim()
|
||||
@@ -20,12 +21,18 @@ export const getExternalFaviconUrl = (rawUrl, sizePx = 32) => {
|
||||
if (u.protocol !== 'http:' && u.protocol !== 'https:') {
|
||||
return ''
|
||||
}
|
||||
const host = u.hostname
|
||||
if (!host) {
|
||||
if (!u.hostname) {
|
||||
return ''
|
||||
}
|
||||
const sz = Number.isFinite(sizePx) && sizePx > 0 ? Math.min(128, Math.round(sizePx)) : 32
|
||||
return `https://www.google.com/s2/favicons?domain=${encodeURIComponent(host)}&sz=${sz}`
|
||||
const params = new URLSearchParams({
|
||||
client: 'SOCIAL',
|
||||
type: 'FAVICON',
|
||||
fallback_opts: 'TYPE,SIZE,URL',
|
||||
url: u.origin,
|
||||
size: String(sz)
|
||||
})
|
||||
return `https://t3.gstatic.com/faviconV2?${params.toString()}`
|
||||
} catch {
|
||||
return ''
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "sori.studio",
|
||||
"version": "1.6.0",
|
||||
"version": "1.6.1",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"imports": {
|
||||
|
||||
@@ -955,7 +955,7 @@ const saveNavigation = async () => {
|
||||
|
||||
<div v-show="activeTab === 'recommended'" class="admin-navigation__panel-recommended space-y-4">
|
||||
<p class="admin-navigation__recommended-note max-w-xl text-sm text-muted">
|
||||
공개 홈 우측 사이드바 Recommended 영역에 표시됩니다. 대체 텍스트가 있으면 카드 하단에 URL 대신 표시하고, 썸네일 URL이 있으면 Google 파비콘 대신 썸네일을 사용합니다.
|
||||
공개 홈 왼쪽 사이드바 Recommended 영역에 표시됩니다. 대체 텍스트가 있으면 카드 하단에 URL 대신 표시하고, 썸네일 URL이 있으면 자동 파비콘 대신 썸네일을 사용합니다.
|
||||
</p>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<button
|
||||
|
||||
Reference in New Issue
Block a user