본문 갤러리 이미지 계산 분리
This commit is contained in:
@@ -41,6 +41,7 @@ import {
|
|||||||
normalizeQuoteEditableValue
|
normalizeQuoteEditableValue
|
||||||
} from '../../lib/markdown-quote.js'
|
} from '../../lib/markdown-quote.js'
|
||||||
import { getInArticleAdAfterBlockIds } from '../../lib/content-in-article-ad.js'
|
import { getInArticleAdAfterBlockIds } from '../../lib/content-in-article-ad.js'
|
||||||
|
import { getContentLightboxImages, getGalleryRows } from '../../lib/content-gallery-images.js'
|
||||||
import { parseMarkdownBlocks } from '../../lib/markdown-block-parser.js'
|
import { parseMarkdownBlocks } from '../../lib/markdown-block-parser.js'
|
||||||
import { createHeadingIdFactory } from '../../lib/markdown-toc.js'
|
import { createHeadingIdFactory } from '../../lib/markdown-toc.js'
|
||||||
import ContentMarkdownCodeBlockEditor from './ContentMarkdownCodeBlockEditor.vue'
|
import ContentMarkdownCodeBlockEditor from './ContentMarkdownCodeBlockEditor.vue'
|
||||||
@@ -203,52 +204,7 @@ const tailInsertBeforeLine = computed(() => {
|
|||||||
})
|
})
|
||||||
const activeLightboxImage = computed(() => activeLightboxImages.value[activeLightboxIndex.value])
|
const activeLightboxImage = computed(() => activeLightboxImages.value[activeLightboxIndex.value])
|
||||||
|
|
||||||
const contentLightboxImages = computed(() => {
|
const contentLightboxImages = computed(() => getContentLightboxImages(blocks.value))
|
||||||
const images = []
|
|
||||||
|
|
||||||
blocks.value.forEach((block) => {
|
|
||||||
if (block.type === 'image' && block.url) {
|
|
||||||
images.push({
|
|
||||||
...block,
|
|
||||||
lightboxKey: block.id
|
|
||||||
})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (block.type === 'gallery' && Array.isArray(block.images)) {
|
|
||||||
block.images.forEach((image, imageIndex) => {
|
|
||||||
if (!image?.url) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
images.push({
|
|
||||||
...image,
|
|
||||||
lightboxKey: `${block.id}:${imageIndex}`
|
|
||||||
})
|
|
||||||
})
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
return images
|
|
||||||
})
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 갤러리 이미지를 행 단위로 최대 3개씩 나눈다.
|
|
||||||
* @param {Array<Object>} images - 갤러리 이미지 목록
|
|
||||||
* @returns {Array<{ startIndex: number, images: Array<Object> }>} 행 목록
|
|
||||||
*/
|
|
||||||
const getGalleryRows = (images) => {
|
|
||||||
const rows = []
|
|
||||||
|
|
||||||
for (let index = 0; index < images.length; index += 3) {
|
|
||||||
rows.push({
|
|
||||||
startIndex: index,
|
|
||||||
images: images.slice(index, index + 3)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
return rows
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 갤러리 이미지 비율 저장 키를 만든다.
|
* 갤러리 이미지 비율 저장 키를 만든다.
|
||||||
|
|||||||
@@ -33,6 +33,7 @@
|
|||||||
|------|------|
|
|------|------|
|
||||||
| lib/upload-size-limit.js | 관리자 미디어 업로드 종류별 최대 바이트 판별·1회 파일 수/총량 제한 정규화·용량 문구(서버·클라이언트 공용) |
|
| lib/upload-size-limit.js | 관리자 미디어 업로드 종류별 최대 바이트 판별·1회 파일 수/총량 제한 정규화·용량 문구(서버·클라이언트 공용) |
|
||||||
| lib/content-in-article-ad.js | 공개 Markdown 본문 인아티클 광고 삽입 후보·목표 위치 계산 |
|
| lib/content-in-article-ad.js | 공개 Markdown 본문 인아티클 광고 삽입 후보·목표 위치 계산 |
|
||||||
|
| lib/content-gallery-images.js | 공개 Markdown 본문 단일 이미지·갤러리 라이트박스 목록 생성과 갤러리 행 분할 계산 |
|
||||||
| lib/content-image-urls.js | 공개 Markdown 본문 이미지 표시 URL·라이트박스 URL·srcset·갤러리 sizes 계산 |
|
| lib/content-image-urls.js | 공개 Markdown 본문 이미지 표시 URL·라이트박스 URL·srcset·갤러리 sizes 계산 |
|
||||||
| lib/admin-social-settings-section.js | 관리자 설정 SNS 설정 필드 목록·기본 스냅샷·정규화·스냅샷 복사·변경 비교 helper |
|
| lib/admin-social-settings-section.js | 관리자 설정 SNS 설정 필드 목록·기본 스냅샷·정규화·스냅샷 복사·변경 비교 helper |
|
||||||
| lib/admin-post-settings-section.js | 관리자 설정 POST 설정 필드 목록·기본 스냅샷·정규화·스냅샷 복사·변경 비교 helper |
|
| lib/admin-post-settings-section.js | 관리자 설정 POST 설정 필드 목록·기본 스냅샷·정규화·스냅샷 복사·변경 비교 helper |
|
||||||
@@ -111,6 +112,7 @@
|
|||||||
| docker-compose.test.yml | 실행마다 생성·폐기하는 PostgreSQL 16 테스트 DB·볼륨·네트워크 |
|
| docker-compose.test.yml | 실행마다 생성·폐기하는 PostgreSQL 16 테스트 DB·볼륨·네트워크 |
|
||||||
| tests/markdown-html-block.test.js | HTML sanitizer 단위 회귀 테스트 |
|
| tests/markdown-html-block.test.js | HTML sanitizer 단위 회귀 테스트 |
|
||||||
| tests/content-in-article-ad.test.js | 공개 본문 인아티클 광고 삽입 위치 계산 단위 회귀 테스트 |
|
| tests/content-in-article-ad.test.js | 공개 본문 인아티클 광고 삽입 위치 계산 단위 회귀 테스트 |
|
||||||
|
| tests/content-gallery-images.test.js | 공개 본문 라이트박스 이미지 목록과 갤러리 행 분할 단위 회귀 테스트 |
|
||||||
| tests/content-image-urls.test.js | 공개 본문 이미지 파생 URL·srcset·갤러리 sizes 단위 회귀 테스트 |
|
| tests/content-image-urls.test.js | 공개 본문 이미지 파생 URL·srcset·갤러리 sizes 단위 회귀 테스트 |
|
||||||
| tests/markdown-block-context.test.js | 관리자 블록 설정 패널의 활성 블록 컨텍스트 단위 회귀 테스트 |
|
| tests/markdown-block-context.test.js | 관리자 블록 설정 패널의 활성 블록 컨텍스트 단위 회귀 테스트 |
|
||||||
| tests/markdown-block-parser.test.js | 공개 Markdown 본문 블록 파싱 단위 회귀 테스트 |
|
| tests/markdown-block-parser.test.js | 공개 Markdown 본문 블록 파싱 단위 회귀 테스트 |
|
||||||
|
|||||||
@@ -127,7 +127,7 @@
|
|||||||
|
|
||||||
- 관리자 미디어 업로드 API는 JPG·PNG·WebP 파일을 `/uploads/posts/YYYY/MM/원본파일`에 저장한다. 업로드만으로는 카드 썸네일을 만들지 않고, 해당 이미지가 게시물 대표 이미지로 저장될 때 목록 카드용 WebP 썸네일을 `/uploads/posts/YYYY/MM/thumbs/원본파일명-card.webp`에 생성한다.
|
- 관리자 미디어 업로드 API는 JPG·PNG·WebP 파일을 `/uploads/posts/YYYY/MM/원본파일`에 저장한다. 업로드만으로는 카드 썸네일을 만들지 않고, 해당 이미지가 게시물 대표 이미지로 저장될 때 목록 카드용 WebP 썸네일을 `/uploads/posts/YYYY/MM/thumbs/원본파일명-card.webp`에 생성한다.
|
||||||
- 카드 썸네일은 640×360 기준 `cover` 리사이즈와 WebP 품질 82를 사용한다. GIF·동영상·문서 파일은 썸네일을 자동 생성하지 않는다.
|
- 카드 썸네일은 640×360 기준 `cover` 리사이즈와 WebP 품질 82를 사용한다. GIF·동영상·문서 파일은 썸네일을 자동 생성하지 않는다.
|
||||||
- 본문 표시용 이미지는 1600px `thumbs/*-body-1600.webp`와 720px `thumbs/*-body-720.webp`를 함께 생성하고, 공개 본문 단일 이미지·갤러리는 `lib/content-image-urls.js`의 `srcset` 계산으로 화면 폭에 맞는 파일을 우선 사용한다.
|
- 본문 표시용 이미지는 1600px `thumbs/*-body-1600.webp`와 720px `thumbs/*-body-720.webp`를 함께 생성하고, 공개 본문 단일 이미지·갤러리는 `lib/content-image-urls.js`의 `srcset` 계산으로 화면 폭에 맞는 파일을 우선 사용한다. 공개 본문 라이트박스 대상 목록과 갤러리 행 분할은 `lib/content-gallery-images.js`에서 계산한다.
|
||||||
- 라이트박스용 이미지는 2560px `thumbs/*-body-2560.webp`를 생성해 크게 보기 화면에서 우선 사용한다. 별도 원본 보기 UI는 제공하지 않으며, `lib/content-image-urls.js`가 파생 URL을 선택하고 파생 이미지 로드 실패 시에만 원본으로 fallback한다.
|
- 라이트박스용 이미지는 2560px `thumbs/*-body-2560.webp`를 생성해 크게 보기 화면에서 우선 사용한다. 별도 원본 보기 UI는 제공하지 않으며, `lib/content-image-urls.js`가 파생 URL을 선택하고 파생 이미지 로드 실패 시에만 원본으로 fallback한다.
|
||||||
- 기존 운영 볼륨에 생성된 `thumbs/*-body.webp`, `thumbs/*-lightbox.webp`는 레거시 본문 파생 이미지로 계속 인식해 관리자 미디어의 본문 이미지 탭과 사용처 연결을 유지한다. 새 저장·재생성·백필은 해상도형 파일명만 만든다.
|
- 기존 운영 볼륨에 생성된 `thumbs/*-body.webp`, `thumbs/*-lightbox.webp`는 레거시 본문 파생 이미지로 계속 인식해 관리자 미디어의 본문 이미지 탭과 사용처 연결을 유지한다. 새 저장·재생성·백필은 해상도형 파일명만 만든다.
|
||||||
- 관리자 미디어의 원본 이미지 사용처는 원본 URL 직접 참조뿐 아니라 해당 원본에서 생성된 카드·페이지 커버·본문 파생 이미지 URL 사용처까지 함께 반영한다. 사용처 계산은 관리자 글 목록 요약 API와 분리된 미디어 참조 전용 조회로 게시물·페이지의 본문과 대표 이미지만 확인한다. 따라서 공개 본문이 `body-1600` 같은 파생 이미지를 사용하더라도 원본은 미사용 삭제 대상에서 제외되고, 본문 파생 이미지도 실제 본문 사용처가 있으면 미사용으로 분류하지 않는다. 원본이 실제로 미사용이라 삭제될 때는 연결된 카드·페이지 커버·본문 자동 생성 이미지도 함께 삭제한다.
|
- 관리자 미디어의 원본 이미지 사용처는 원본 URL 직접 참조뿐 아니라 해당 원본에서 생성된 카드·페이지 커버·본문 파생 이미지 URL 사용처까지 함께 반영한다. 사용처 계산은 관리자 글 목록 요약 API와 분리된 미디어 참조 전용 조회로 게시물·페이지의 본문과 대표 이미지만 확인한다. 따라서 공개 본문이 `body-1600` 같은 파생 이미지를 사용하더라도 원본은 미사용 삭제 대상에서 제외되고, 본문 파생 이미지도 실제 본문 사용처가 있으면 미사용으로 분류하지 않는다. 원본이 실제로 미사용이라 삭제될 때는 연결된 카드·페이지 커버·본문 자동 생성 이미지도 함께 삭제한다.
|
||||||
@@ -302,10 +302,10 @@ components/content/
|
|||||||
- 캡션(표시용): `` — 따옴표 안 문자열만 `ProseImage` figcaption으로 표시
|
- 캡션(표시용): `` — 따옴표 안 문자열만 `ProseImage` figcaption으로 표시
|
||||||
- **파일명을 캡션으로 사용** 토글: URL 파일명을 캡션으로 저장·표시(``). 레거시 ``도 동일하게 해석
|
- **파일명을 캡션으로 사용** 토글: URL 파일명을 캡션으로 저장·표시(``). 레거시 ``도 동일하게 해석
|
||||||
- 와이드/풀: `{width=wide|full}` 또는 캡션·width 조합
|
- 와이드/풀: `{width=wide|full}` 또는 캡션·width 조합
|
||||||
- 렌더링: `ProseImage.vue` (라운드/보더/패널 배경). 클릭 시 라이트박스로 확대하며, 같은 게시물 안의 단일 이미지와 갤러리 이미지를 문서 순서대로 하나의 그룹으로 묶어 Esc 닫기·←/→ 이전·다음 탐색을 지원한다.
|
- 렌더링: `ProseImage.vue` (라운드/보더/패널 배경). 클릭 시 라이트박스로 확대하며, 같은 게시물 안의 단일 이미지와 갤러리 이미지를 `lib/content-gallery-images.js` 기준 문서 순서대로 하나의 그룹으로 묶어 Esc 닫기·←/→ 이전·다음 탐색을 지원한다.
|
||||||
- 이미지 갤러리
|
- 이미지 갤러리
|
||||||
- `:::gallery` ~ `:::` fenced block 내부에 이미지 마크다운 행을 여러 개 작성
|
- `:::gallery` ~ `:::` fenced block 내부에 이미지 마크다운 행을 여러 개 작성
|
||||||
- 렌더링: `ContentMarkdownRenderer.vue` (최대 3개 단위 행 + 게시물 전체 이미지 라이트박스, Esc 닫기·←/→ 이전·다음)
|
- 렌더링: `ContentMarkdownRenderer.vue` (최대 3개 단위 행 + 게시물 전체 이미지 라이트박스, Esc 닫기·←/→ 이전·다음). 행 분할과 라이트박스 목록 생성은 `lib/content-gallery-images.js`에서 수행한다.
|
||||||
- 갤러리 행은 1개일 때 전체 폭, 2~3개일 때 행 전체 폭을 나눠 쓰며 이미지 로드 후 자연 비율(가로/세로)에 따라 셀 너비를 조정한다.
|
- 갤러리 행은 1개일 때 전체 폭, 2~3개일 때 행 전체 폭을 나눠 쓰며 이미지 로드 후 자연 비율(가로/세로)에 따라 셀 너비를 조정한다.
|
||||||
- 비디오·오디오·파일 카드
|
- 비디오·오디오·파일 카드
|
||||||
- 비디오: `:::video` ~ `:::` (`url`, `title`, `poster`, `caption` 키값 또는 URL 단독 줄)
|
- 비디오: `:::video` ~ `:::` (`url`, `title`, `poster`, `caption` 키값 또는 URL 단독 줄)
|
||||||
|
|||||||
@@ -1,5 +1,11 @@
|
|||||||
# 업데이트 이력
|
# 업데이트 이력
|
||||||
|
|
||||||
|
## v1.11.56
|
||||||
|
|
||||||
|
- 공개 Markdown 렌더러의 라이트박스 이미지 목록과 갤러리 행 분할 계산을 `lib/content-gallery-images.js`로 분리.
|
||||||
|
- 단일 이미지·갤러리 이미지 `lightboxKey` 생성과 갤러리 3열 행 분할 단위 테스트를 추가.
|
||||||
|
- `ContentMarkdownRenderer`는 라이트박스 상태와 갤러리 이벤트 연결에 집중하도록 정리.
|
||||||
|
|
||||||
## v1.11.55
|
## v1.11.55
|
||||||
|
|
||||||
- 공개 Markdown 렌더러의 본문 이미지 표시·라이트박스·srcset·갤러리 sizes 계산을 `lib/content-image-urls.js`로 분리.
|
- 공개 Markdown 렌더러의 본문 이미지 표시·라이트박스·srcset·갤러리 sizes 계산을 `lib/content-image-urls.js`로 분리.
|
||||||
|
|||||||
56
lib/content-gallery-images.js
Normal file
56
lib/content-gallery-images.js
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
/**
|
||||||
|
* 공개 본문 라이트박스 이미지 목록을 만든다.
|
||||||
|
* @param {Array<Object>} blocks - Markdown 렌더링 블록 목록
|
||||||
|
* @returns {Array<Object>} 라이트박스 이미지 목록
|
||||||
|
*/
|
||||||
|
export const getContentLightboxImages = (blocks) => {
|
||||||
|
const images = []
|
||||||
|
|
||||||
|
for (const block of blocks || []) {
|
||||||
|
if (block?.type === 'image' && block.url) {
|
||||||
|
images.push({
|
||||||
|
...block,
|
||||||
|
lightboxKey: block.id
|
||||||
|
})
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if (block?.type !== 'gallery' || !Array.isArray(block.images)) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
block.images.forEach((image, imageIndex) => {
|
||||||
|
if (!image?.url) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
images.push({
|
||||||
|
...image,
|
||||||
|
lightboxKey: `${block.id}:${imageIndex}`
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return images
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 갤러리 이미지를 행 단위로 나눈다.
|
||||||
|
* @param {Array<Object>} images - 갤러리 이미지 목록
|
||||||
|
* @param {number} [rowSize=3] - 행당 이미지 개수
|
||||||
|
* @returns {Array<{ startIndex: number, images: Array<Object> }>} 행 목록
|
||||||
|
*/
|
||||||
|
export const getGalleryRows = (images, rowSize = 3) => {
|
||||||
|
const safeImages = Array.isArray(images) ? images : []
|
||||||
|
const safeRowSize = Number.isInteger(rowSize) && rowSize > 0 ? rowSize : 3
|
||||||
|
const rows = []
|
||||||
|
|
||||||
|
for (let index = 0; index < safeImages.length; index += safeRowSize) {
|
||||||
|
rows.push({
|
||||||
|
startIndex: index,
|
||||||
|
images: safeImages.slice(index, index + safeRowSize)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return rows
|
||||||
|
}
|
||||||
4
package-lock.json
generated
4
package-lock.json
generated
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "sori.studio",
|
"name": "sori.studio",
|
||||||
"version": "1.11.55",
|
"version": "1.11.56",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "sori.studio",
|
"name": "sori.studio",
|
||||||
"version": "1.11.55",
|
"version": "1.11.56",
|
||||||
"hasInstallScript": true,
|
"hasInstallScript": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@nuxtjs/tailwindcss": "^6.14.0",
|
"@nuxtjs/tailwindcss": "^6.14.0",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "sori.studio",
|
"name": "sori.studio",
|
||||||
"version": "1.11.55",
|
"version": "1.11.56",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"imports": {
|
"imports": {
|
||||||
|
|||||||
76
tests/content-gallery-images.test.js
Normal file
76
tests/content-gallery-images.test.js
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
import assert from 'node:assert/strict'
|
||||||
|
import test from 'node:test'
|
||||||
|
import {
|
||||||
|
getContentLightboxImages,
|
||||||
|
getGalleryRows
|
||||||
|
} from '../lib/content-gallery-images.js'
|
||||||
|
|
||||||
|
test('본문 라이트박스 목록은 단일 이미지와 갤러리 이미지를 원문 순서대로 펼친다', () => {
|
||||||
|
const images = getContentLightboxImages([
|
||||||
|
{
|
||||||
|
id: 'block-1',
|
||||||
|
type: 'image',
|
||||||
|
url: '/uploads/posts/2026/07/a.jpg',
|
||||||
|
alt: '단일'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'block-2',
|
||||||
|
type: 'paragraph',
|
||||||
|
text: '본문'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'block-3',
|
||||||
|
type: 'gallery',
|
||||||
|
images: [
|
||||||
|
{ url: '/uploads/posts/2026/07/b.jpg', alt: '갤러리 1' },
|
||||||
|
{ url: '', alt: '빈 이미지' },
|
||||||
|
{ url: '/uploads/posts/2026/07/c.jpg', alt: '갤러리 3' }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
])
|
||||||
|
|
||||||
|
assert.deepEqual(
|
||||||
|
images.map((image) => [image.url, image.lightboxKey]),
|
||||||
|
[
|
||||||
|
['/uploads/posts/2026/07/a.jpg', 'block-1'],
|
||||||
|
['/uploads/posts/2026/07/b.jpg', 'block-3:0'],
|
||||||
|
['/uploads/posts/2026/07/c.jpg', 'block-3:2']
|
||||||
|
]
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('본문 라이트박스 목록은 이미지 URL이 없는 블록을 제외한다', () => {
|
||||||
|
assert.deepEqual(getContentLightboxImages([
|
||||||
|
{ id: 'block-1', type: 'image', url: '' },
|
||||||
|
{ id: 'block-2', type: 'gallery', images: null },
|
||||||
|
{ id: 'block-3', type: 'gallery', images: [{ alt: 'url 없음' }] }
|
||||||
|
]), [])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('갤러리 행은 기본 3개 단위로 나눈다', () => {
|
||||||
|
const rows = getGalleryRows([
|
||||||
|
{ url: '1.jpg' },
|
||||||
|
{ url: '2.jpg' },
|
||||||
|
{ url: '3.jpg' },
|
||||||
|
{ url: '4.jpg' },
|
||||||
|
{ url: '5.jpg' },
|
||||||
|
{ url: '6.jpg' },
|
||||||
|
{ url: '7.jpg' }
|
||||||
|
])
|
||||||
|
|
||||||
|
assert.deepEqual(
|
||||||
|
rows.map((row) => [row.startIndex, row.images.map((image) => image.url)]),
|
||||||
|
[
|
||||||
|
[0, ['1.jpg', '2.jpg', '3.jpg']],
|
||||||
|
[3, ['4.jpg', '5.jpg', '6.jpg']],
|
||||||
|
[6, ['7.jpg']]
|
||||||
|
]
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('갤러리 행 분할은 잘못된 입력을 빈 목록으로 처리한다', () => {
|
||||||
|
assert.deepEqual(getGalleryRows(null), [])
|
||||||
|
assert.deepEqual(getGalleryRows([{ url: '1.jpg' }, { url: '2.jpg' }], 0), [
|
||||||
|
{ startIndex: 0, images: [{ url: '1.jpg' }, { url: '2.jpg' }] }
|
||||||
|
])
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user