feat(browser): Enhance UI/UX and fix multiple bugs
This commit introduces a major overhaul to the AIIA Browser, addressing several UI/UX issues and bugs to create a more robust and intuitive experience.
Key changes include:
1. **Fix List View Button Rendering**: Resolved a race condition where the "Load Workflow" button wouldn't appear in list view. The browser now waits for all metadata to be loaded before the initial render, ensuring UI consistency.
2. **Fix Workflow Name on Load**: Correctly sets the workflow name in the ComfyUI title bar upon loading by using the appropriate API for both standard and API-formatted workflows.
3. **Fix Icon Sizing Slider**: The icon size slider now updates the view instantly and correctly by triggering a full, clean re-render of the icon view, preventing display artifacts and state conflicts.
4. **Implement Directory Refresh**:
- Added a manual refresh button to the navigation bar.
- The browser now automatically refreshes the directory content when switching between icon and list views. This ensures that newly created or deleted files are correctly reflected in the UI.
5. **Refactor Icon Button Layout**: The download and load workflow buttons in the icon view are now a hover-activated overlay at the top of the thumbnail, improving the visual layout.
6. **Backend Enhancements**: Includes various improvements to the Python backend for more robust thumbnail generation and metadata extraction.
---
feat(浏览器): 全面优化UI/UX并修复多个Bug
本次提交对AIIA浏览器进行了一次重大的更新,解决了一系列UI/UX问题和Bug,旨在提供一个更稳定、更直观的用户体验。
主要变更包括:
1. **修复列表视图按钮渲染问题**: 解决了一个因竞态条件导致的“加载工作流”按钮在列表视图中不显示的Bug。浏览器现在会等待所有元数据加载完毕后才进行初次渲染,确保了UI的一致性。
2. **修复加载工作流时的名称设置**: 通过为标准格式和API格式的工作流调用正确的API,现在加载工作流后能正确地在ComfyUI标题栏设置其名称。
3. **修复图标缩���滑块**: 图标大小滑块现在可以通过触发一次完整的、干净的视图重绘来即时、正确地更新视图,避免了显示错位和状态冲突。
4. **实现目录刷新功能**:
- 在导航栏添加了手动刷新按钮。
- 现在当在图标和列表视图之间切换时,浏览器会自动刷新目录内容,确保新建或删除的文件能被正确地反映在界面上。
5. **重构图标按钮布局**: 图标视图中的下载和加载工作流按钮现在改为在鼠标悬停时,在缩略图顶部显示为一个浮层,优化了视觉布局。
6. **后端增强**: 包含对Python后端的若干改进,使缩略图生成和元数据提取更加健壮。
This commit is contained in:
+72
-21
@@ -75,7 +75,10 @@ async def _get_file_metadata(file_path: Path):
|
||||
try:
|
||||
if ext in {'.png', '.jpg', '.jpeg', '.gif', '.webp'}:
|
||||
with Image.open(file_path) as img:
|
||||
img.load() # Force loading of metadata chunks
|
||||
metadata["width"], metadata["height"] = img.size
|
||||
if 'prompt' in img.info or 'workflow' in img.info:
|
||||
metadata['has_workflow'] = True
|
||||
elif ext in {'.mp4', '.mov', '.avi', '.wav', '.mp3', '.ogg'}:
|
||||
if not shutil.which("ffprobe"): return metadata
|
||||
command = ["ffprobe", "-v", "quiet", "-print_format", "json", "-show_format", "-show_streams", str(file_path)]
|
||||
@@ -146,7 +149,7 @@ async def get_thumbnail(request):
|
||||
cached_thumb_path = cache_sub_dir / f"{Path(filename).stem}.jpg"
|
||||
|
||||
if cached_thumb_path.exists() and cached_thumb_path.stat().st_mtime > original_file_path.stat().st_mtime:
|
||||
return web.FileResponse(cached_thumb_path, headers={'Content-Type': 'image/jpeg'})
|
||||
return web.FileResponse(cached_thumb_path, headers={'Content-Type': 'image/jpeg', 'Cache-Control': 'public, must-revalidate'})
|
||||
|
||||
with Image.open(original_file_path) as img:
|
||||
img = ImageOps.exif_transpose(img)
|
||||
@@ -164,7 +167,7 @@ async def get_thumbnail(request):
|
||||
|
||||
asyncio.create_task(write_cache())
|
||||
|
||||
return web.Response(body=buffer.getvalue(), content_type='image/jpeg')
|
||||
return web.Response(body=buffer.getvalue(), content_type='image/jpeg', headers={'Cache-Control': 'public, must-revalidate'})
|
||||
|
||||
except Exception as e:
|
||||
traceback.print_exc()
|
||||
@@ -180,45 +183,49 @@ async def get_video_poster(request):
|
||||
|
||||
try:
|
||||
original_file_path = get_safe_path(output_dir, os.path.join(relative_path_str, filename))
|
||||
if not original_file_path.is_file(): return web.Response(status=404, text="File not found")
|
||||
if not original_file_path.is_file() or original_file_path.stat().st_size == 0:
|
||||
return web.Response(status=404, text="File not found or is empty")
|
||||
|
||||
cache_sub_dir = get_safe_path(video_poster_dir, relative_path_str)
|
||||
cache_sub_dir.mkdir(parents=True, exist_ok=True)
|
||||
cached_poster_path = cache_sub_dir / f"{Path(filename).stem}.jpg"
|
||||
|
||||
if cached_poster_path.exists() and cached_poster_path.stat().st_mtime > original_file_path.stat().st_mtime:
|
||||
return web.FileResponse(cached_poster_path, headers={'Content-Type': 'image/jpeg'})
|
||||
if cached_poster_path.exists():
|
||||
# If the cached file is valid and up-to-date, serve it.
|
||||
if cached_poster_path.stat().st_size > 0 and cached_poster_path.stat().st_mtime > original_file_path.stat().st_mtime:
|
||||
return web.FileResponse(cached_poster_path, headers={'Content-Type': 'image/jpeg', 'Cache-Control': 'public, must-revalidate'})
|
||||
else:
|
||||
# Delete invalid (0-byte) or outdated cache files.
|
||||
print(f"--- [AIIA Browser Debug] Deleting invalid or outdated cache file: {cached_poster_path}")
|
||||
os.remove(cached_poster_path)
|
||||
|
||||
# 使用临时文件确保 ffmpeg 操作的原子性
|
||||
with tempfile.NamedTemporaryFile(delete=False, suffix=".jpg") as tmpfile:
|
||||
tmp_poster_path = tmpfile.name
|
||||
|
||||
|
||||
# Use the 'thumbnail' filter with the 'update' flag, which is robust for both
|
||||
# normal videos and special cases like single-frame videos.
|
||||
command = [
|
||||
"ffmpeg",
|
||||
"-ss", "00:00:01.00",
|
||||
"-i", str(original_file_path),
|
||||
"-vf", "thumbnail,scale=256:-1:force_original_aspect_ratio=decrease",
|
||||
"-frames:v", "1",
|
||||
"-vf", "thumbnail,scale=256:-1",
|
||||
"-y", # Overwrite output file if it exists
|
||||
"-update", "1",
|
||||
"-q:v", "3",
|
||||
"-y",
|
||||
str(tmp_poster_path)
|
||||
]
|
||||
|
||||
proc = await asyncio.create_subprocess_exec(*command, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE)
|
||||
stdout, stderr = await proc.communicate()
|
||||
_, stderr = await proc.communicate()
|
||||
|
||||
if proc.returncode != 0:
|
||||
if proc.returncode != 0 or not os.path.exists(tmp_poster_path) or os.path.getsize(tmp_poster_path) == 0:
|
||||
if os.path.exists(tmp_poster_path): os.remove(tmp_poster_path)
|
||||
print(f"[AIIA] FFmpeg error for {filename}: {stderr.decode()}")
|
||||
return web.Response(status=500, text="Failed to extract frame from video.")
|
||||
print(f"[AIIA] FFmpeg failed to extract thumbnail for {filename}. Error: {stderr.decode()}")
|
||||
return web.Response(status=500, text="Failed to extract thumbnail from video.")
|
||||
|
||||
# 检查生成的文件是否有效
|
||||
if not os.path.exists(tmp_poster_path) or os.path.getsize(tmp_poster_path) == 0:
|
||||
if os.path.exists(tmp_poster_path): os.remove(tmp_poster_path)
|
||||
return web.Response(status=500, text="FFmpeg produced an empty file.")
|
||||
|
||||
# 原子性地移动/重命名临时文件到缓存位置
|
||||
shutil.move(tmp_poster_path, cached_poster_path)
|
||||
return web.FileResponse(cached_poster_path, headers={'Content-Type': 'image/jpeg', 'Cache-Control': 'public, must-revalidate'})
|
||||
|
||||
return web.FileResponse(cached_poster_path, headers={'Content-Type': 'image/jpeg'})
|
||||
except Exception as e:
|
||||
if 'tmp_poster_path' in locals() and os.path.exists(tmp_poster_path):
|
||||
os.remove(tmp_poster_path)
|
||||
@@ -245,10 +252,54 @@ async def get_batch_metadata(request):
|
||||
traceback.print_exc()
|
||||
return web.Response(status=500, text=str(e))
|
||||
|
||||
import math
|
||||
|
||||
def replace_nan_with_null(obj):
|
||||
"""
|
||||
Recursively traverses a Python object (dicts, lists) and replaces
|
||||
float('nan') with None, which serializes to JSON 'null'.
|
||||
"""
|
||||
if isinstance(obj, dict):
|
||||
return {k: replace_nan_with_null(v) for k, v in obj.items()}
|
||||
elif isinstance(obj, list):
|
||||
return [replace_nan_with_null(elem) for elem in obj]
|
||||
# The core of the fix: only replace actual float NaN values
|
||||
elif isinstance(obj, float) and math.isnan(obj):
|
||||
return None
|
||||
else:
|
||||
return obj
|
||||
|
||||
async def get_workflow(request):
|
||||
relative_path_str = request.query.get("path", "")
|
||||
filename = request.query.get("filename", "")
|
||||
if not filename:
|
||||
return web.Response(status=400, text="Filename is required.")
|
||||
|
||||
try:
|
||||
file_path = get_safe_path(output_dir, os.path.join(relative_path_str, filename))
|
||||
if not file_path.is_file():
|
||||
return web.Response(status=404, text="File not found")
|
||||
|
||||
with Image.open(file_path) as img:
|
||||
# Prioritize the full 'workflow' data if it exists, otherwise fall back to 'prompt'.
|
||||
workflow_data_str = img.info.get('workflow') or img.info.get('prompt')
|
||||
|
||||
if workflow_data_str:
|
||||
workflow_obj = json.loads(workflow_data_str)
|
||||
sanitized_workflow_obj = replace_nan_with_null(workflow_obj)
|
||||
return web.json_response(sanitized_workflow_obj)
|
||||
else:
|
||||
return web.Response(status=404, text="No workflow data found in the image.")
|
||||
|
||||
except Exception as e:
|
||||
traceback.print_exc()
|
||||
return web.Response(status=500, text=str(e))
|
||||
|
||||
server.PromptServer.instance.app.router.add_get('/api/aiia/v1/browser/list_items', list_items)
|
||||
server.PromptServer.instance.app.router.add_post('/api/aiia/v1/browser/get_batch_metadata', get_batch_metadata)
|
||||
server.PromptServer.instance.app.router.add_get('/api/aiia/v1/browser/thumbnail', get_thumbnail)
|
||||
server.PromptServer.instance.app.router.add_get('/api/aiia/v1/browser/poster', get_video_poster)
|
||||
server.PromptServer.instance.app.router.add_get('/api/aiia/v1/browser/get_workflow', get_workflow)
|
||||
|
||||
NODE_CLASS_MAPPINGS = {}
|
||||
NODE_DISPLAY_NAME_MAPPINGS = {}
|
||||
|
||||
+939
-33
File diff suppressed because it is too large
Load Diff
+141
-18
@@ -1,4 +1,4 @@
|
||||
// aiia_browser_styles.js (V67 - 设置面板样式修复)
|
||||
// aiia_browser_styles.js (V82 - 虚拟滚动引擎重构)
|
||||
export const browserStyles = `
|
||||
/* Main Browser Styles */
|
||||
#aiia-browser-menu-button { margin-left: 10px; }
|
||||
@@ -77,16 +77,65 @@ export const browserStyles = `
|
||||
.aiia-grid-wrapper::-webkit-scrollbar-thumb, .aiia-list-body::-webkit-scrollbar-thumb, .aiia-custom-tooltip::-webkit-scrollbar-thumb, .aiia-filmstrip-container::-webkit-scrollbar-thumb, .aiia-directory-list::-webkit-scrollbar-thumb { background-color: rgba(155, 155, 155, 0.5); border-radius: 8px; border: 4px solid transparent; background-clip: content-box; }
|
||||
.aiia-grid-wrapper:hover::-webkit-scrollbar-thumb, .aiia-list-container:hover .aiia-list-body::-webkit-scrollbar-thumb, .aiia-filmstrip-container:hover::-webkit-scrollbar-thumb, .aiia-directory-list:hover::-webkit-scrollbar-thumb { background-color: rgba(155, 155, 155, 0.8); }
|
||||
|
||||
.aiia-grid-wrapper { height: 100%; width: 100%; overflow-y: auto; overflow-x: hidden; }
|
||||
.aiia-grid-inner-wrapper { display: grid; grid-template-columns: repeat(auto-fill, minmax(var(--aiia-icon-size, 120px), 1fr)); gap: 10px; padding: 10px; }
|
||||
.aiia-item-icon { width: 100%; display: flex; flex-direction: column; border: 1px solid transparent; border-radius: 5px; cursor: pointer; }
|
||||
.aiia-icon-preview { position: relative; height: var(--aiia-icon-size, 120px); min-height: 0; display: flex; flex-direction: column; align-items: center; justify-content: center; width: 100%; overflow: hidden; }
|
||||
.aiia-icon-preview.media-placeholder { background-color: var(--comfy-menu-bg); border: 1px solid var(--border-color); }
|
||||
.aiia-image-preview, .aiia-video-preview { max-width: 100%; max-height: 100%; object-fit: contain; }
|
||||
.aiia-grid-wrapper { height: 100%; width: 100%; overflow-y: scroll; overflow-x: hidden; position: relative; }
|
||||
.aiia-virtual-scroller { position: absolute; top: 0; left: 0; width: 1px; opacity: 0; pointer-events: none; }
|
||||
.aiia-grid-inner-wrapper { display: grid; grid-template-columns: repeat(auto-fill, minmax(var(--aiia-icon-size, 120px), 1fr)); gap: 10px; padding: 10px; position: absolute; top: 0; left: 0; right: 0; }
|
||||
|
||||
.aiia-item-icon {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 5px;
|
||||
cursor: pointer;
|
||||
position: relative;
|
||||
background-color: var(--comfy-menu-bg);
|
||||
overflow: hidden;
|
||||
}
|
||||
.aiia-item-icon:hover .aiia-icon-button-overlay {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
.aiia-icon-preview {
|
||||
position: relative;
|
||||
flex-grow: 1;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
.aiia-icon-preview.media-placeholder { background-color: var(--comfy-box-bg); }
|
||||
.aiia-image-preview, .aiia-video-preview {
|
||||
max-width: 100%;
|
||||
max-height: 100%;
|
||||
object-fit: contain;
|
||||
width: calc(100% - 16px);
|
||||
height: calc(100% - 16px);
|
||||
border-radius: 4px;
|
||||
}
|
||||
.aiia-audio-container { display: flex; flex-direction: column; justify-content: center; align-items: center; width: 100%; height: 100%; gap: 8px; }
|
||||
.aiia-audio-container audio { width: 90%; }
|
||||
.aiia-audio-duration { font-size: 11px; color: var(--descrip-text); }
|
||||
.aiia-item-label { flex-shrink: 0; width: 100%; text-align: center; font-size: 12px; padding: 4px; white-space: normal; word-break: break-all; color: #E5E7EB; }
|
||||
.aiia-item-label {
|
||||
flex-shrink: 0;
|
||||
width: 100%;
|
||||
text-align: center;
|
||||
font-size: 12px;
|
||||
padding: 8px 4px;
|
||||
white-space: normal;
|
||||
word-break: break-all;
|
||||
color: #E5E7EB;
|
||||
line-height: 1.3;
|
||||
background-color: rgba(0,0,0,0.2);
|
||||
}
|
||||
.aiia-item-label.has-workflow {
|
||||
background-color: rgba(59, 130, 246, 0.25);
|
||||
border-top: 1px solid rgba(96, 165, 250, 0.3);
|
||||
color: #eff6ff;
|
||||
}
|
||||
|
||||
.aiia-video-poster-container::after {
|
||||
content: '';
|
||||
@@ -105,7 +154,6 @@ export const browserStyles = `
|
||||
-webkit-mask-size: 60%;
|
||||
mask-size: 60%;
|
||||
-webkit-mask-repeat: no-repeat;
|
||||
mask-repeat: no-repeat;
|
||||
-webkit-mask-position: center;
|
||||
mask-position: center;
|
||||
transition: background-color 0.2s ease-in-out, transform 0.2s ease-in-out;
|
||||
@@ -125,21 +173,95 @@ export const browserStyles = `
|
||||
.aiia-tooltip-label { color: #aaa; text-align: right; white-space: nowrap; }
|
||||
.aiia-tooltip-value { color: #eee; font-weight: bold; white-space: normal; overflow-wrap: break-word; min-width: 0; }
|
||||
|
||||
.aiia-tooltip-metadata audio {
|
||||
width: 100%;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.aiia-tooltip-metadata audio::-webkit-media-controls-play-button,
|
||||
.aiia-tooltip-metadata audio::-webkit-media-controls-mute-button,
|
||||
.aiia-tooltip-metadata audio::-webkit-media-controls-volume-slider {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.aiia-list-container { display: flex; flex-direction: column; height: 100%; width: 100%; }
|
||||
.aiia-list-body { flex-grow: 1; overflow-y: auto; position: relative; }
|
||||
.aiia-list-body-content { }
|
||||
.aiia-list-header, .aiia-list-row { display: grid; grid-template-columns: 28px minmax(150px, 3fr) minmax(140px, 1.5fr) minmax(70px, 1fr) minmax(80px, 1fr) minmax(100px, 1fr) minmax(80px, 1fr); align-items: center; gap: 8px; padding: 0 8px; }
|
||||
.aiia-list-row { border-bottom: 1px solid var(--border-color); cursor: pointer; }
|
||||
.aiia-list-body { flex-grow: 1; overflow-y: scroll; position: relative; }
|
||||
.aiia-list-body-content { position: relative; width: 100%; height: 100%; }
|
||||
.aiia-list-header, .aiia-list-row { display: grid; grid-template-columns: 28px minmax(150px, 3fr) minmax(140px, 1.5fr) minmax(70px, 1fr) minmax(80px, 1fr) minmax(100px, 1fr) minmax(80px, 1fr) 110px; align-items: center; gap: 8px; padding: 0 8px; }
|
||||
.aiia-list-row { border-bottom: 1px solid var(--border-color); cursor: pointer; position: absolute; left: 0; right: 0; }
|
||||
.aiia-list-header { position: sticky; top: 0; z-index: 10; background: var(--comfy-box-bg); border-bottom: 2px solid var(--border-color); }
|
||||
.aiia-list-header-cell { padding: 8px 0; font-weight: bold; color: #F9FAFB; cursor: pointer; user-select: none; }
|
||||
.aiia-list-header-cell:hover { color: var(--link-color); }
|
||||
.aiia-list-row > div { padding: 6px 0; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; color: #E5E7EB; }
|
||||
.aiia-list-cell { padding: 6px 0; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; color: #E5E7EB; }
|
||||
.aiia-list-cell-icon { font-size: 16px; text-align: center; }
|
||||
.aiia-list-cell-actions { display: flex; justify-content: center; align-items: center; gap: 4px; }
|
||||
|
||||
.aiia-icon-button-overlay {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
padding: 8px;
|
||||
box-sizing: border-box;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 6px;
|
||||
background: linear-gradient(to bottom, rgba(0,0,0,0.6), transparent);
|
||||
opacity: 0;
|
||||
transform: translateY(-20px);
|
||||
transition: opacity 0.2s ease-in-out, transform 0.2s ease-in-out;
|
||||
pointer-events: none;
|
||||
}
|
||||
.aiia-item-icon:hover .aiia-icon-button-overlay,
|
||||
.aiia-item-icon.actions-visible .aiia-icon-button-overlay {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
pointer-events: all;
|
||||
}
|
||||
.aiia-icon-button-overlay > button {
|
||||
pointer-events: all;
|
||||
position: static;
|
||||
padding: 4px 8px;
|
||||
font-size: 14px;
|
||||
line-height: 1;
|
||||
background-color: rgba(30, 30, 30, 0.8);
|
||||
color: white;
|
||||
border: 1px solid rgba(255, 255, 255, 0.4);
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
backdrop-filter: blur(2px);
|
||||
transition: background-color 0.2s ease-in-out, transform 0.1s ease-in-out;
|
||||
}
|
||||
.aiia-icon-button-overlay > button:hover {
|
||||
background-color: rgba(0, 123, 255, 0.9);
|
||||
transform: scale(1.05);
|
||||
}
|
||||
.aiia-icon-button-overlay > button:active {
|
||||
transform: scale(1);
|
||||
}
|
||||
|
||||
/* Hide old, now-unused absolute buttons in icon view */
|
||||
.aiia-item-icon > .aiia-load-workflow-button,
|
||||
.aiia-item-icon > .aiia-download-button {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Keep original styles for list view */
|
||||
.aiia-list-row .aiia-load-workflow-button, .aiia-list-row .aiia-download-button {
|
||||
position: static;
|
||||
background-color: transparent;
|
||||
border: none;
|
||||
color: #E5E7EB;
|
||||
font-size: 18px;
|
||||
}
|
||||
.aiia-list-row .aiia-load-workflow-button:hover, .aiia-list-row .aiia-download-button:hover {
|
||||
color: var(--accent-color);
|
||||
background-color: transparent;
|
||||
}
|
||||
|
||||
.aiia-browser-error, .aiia-browser-placeholder { padding: 20px; text-align: center; font-size: 16px; color: var(--descrip-text); width: 100%; }
|
||||
.aiia-empty-file-placeholder { display: flex; flex-direction: column; align-items: center; justify-content: center; width: 100%; height: 100%; color: var(--descrip-text); }
|
||||
.aiia-empty-file-placeholder span:first-child { font-size: 24px; }
|
||||
.aiia-empty-file-placeholder span:last-child { font-size: 11px; margin-top: 4px; }
|
||||
.aiia-empty-file-placeholder, .aiia-no-preview-placeholder { display: flex; flex-direction: column; align-items: center; justify-content: center; width: 100%; height: 100%; color: var(--descrip-text); }
|
||||
.aiia-empty-file-placeholder span:first-child, .aiia-no-preview-placeholder span:first-child { font-size: 24px; }
|
||||
.aiia-empty-file-placeholder span:last-child, .aiia-no-preview-placeholder span:last-child { font-size: 11px; margin-top: 4px; }
|
||||
|
||||
.aiia-progress-container { display: none; flex-direction: column; gap: 4px; margin-bottom: 8px; }
|
||||
.aiia-progress-text { font-size: 12px; color: var(--descrip-text); }
|
||||
@@ -173,4 +295,5 @@ export const browserStyles = `
|
||||
.aiia-info-panel-row:last-child { margin-bottom: 0; }
|
||||
.aiia-info-panel-label { color: #aaa; text-align: right; }
|
||||
.aiia-info-panel-value { font-weight: 500; overflow-wrap: break-word; min-width: 0; }
|
||||
`;
|
||||
`;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user