mirror of
https://github.com/asyouplz/SpeechNote.git
synced 2026-07-22 06:43:33 +00:00
fix(ui): address pr 8004 human review feedback (#70)
Standardize reviewed UI copy to English, namespace plugin CSS selectors, and replace ad hoc SVG creation with shared Obsidian-friendly icons. Also remove the empty fundingUrl field and start tracking styles.css so the review fixes ship with the plugin. Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
fee0dde35c
commit
0f6c3ac218
11 changed files with 3308 additions and 525 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -8,7 +8,6 @@ dist/
|
|||
build/
|
||||
out/
|
||||
main.js
|
||||
styles.css
|
||||
*.js
|
||||
*.js.map
|
||||
*.d.ts
|
||||
|
|
|
|||
|
|
@ -1,3 +1,5 @@
|
|||
import { createIconElement } from '../../utils/common/helpers';
|
||||
|
||||
/**
|
||||
* 드래그 앤 드롭 영역 컴포넌트
|
||||
* - 파일 드래그 앤 드롭 지원
|
||||
|
|
@ -41,28 +43,30 @@ export class DragDropZone {
|
|||
private createDropZone() {
|
||||
if (!this.container) return;
|
||||
|
||||
this.dropZone = this.container.createDiv('drag-drop-zone');
|
||||
this.dropZone = this.container.createDiv('sn-drag-drop-zone');
|
||||
|
||||
// 아이콘
|
||||
const iconContainer = this.dropZone.createDiv('drop-zone-icon');
|
||||
const iconContainer = this.dropZone.createDiv('sn-drop-zone-icon');
|
||||
iconContainer.appendChild(this.createUploadIcon());
|
||||
|
||||
// 메인 텍스트
|
||||
const mainText = this.dropZone.createDiv('drop-zone-text');
|
||||
mainText.createEl('h3', { text: '파일을 여기에 드롭하세요' });
|
||||
const mainText = this.dropZone.createDiv('sn-drop-zone-text');
|
||||
mainText.createEl('h3', { text: 'Drop files here' });
|
||||
|
||||
// 서브 텍스트
|
||||
const subText = mainText.createEl('p', { cls: 'drop-zone-subtext' });
|
||||
subText.setText('또는 클릭하여 파일 선택');
|
||||
const subText = mainText.createEl('p', { cls: 'sn-drop-zone-subtext' });
|
||||
subText.setText('or click to choose files');
|
||||
|
||||
// 지원 형식 표시
|
||||
const formats = mainText.createEl('p', { cls: 'drop-zone-formats' });
|
||||
formats.setText(`지원 형식: ${this.acceptedFormats.map((f) => `.${f}`).join(', ')}`);
|
||||
const formats = mainText.createEl('p', { cls: 'sn-drop-zone-formats' });
|
||||
formats.setText(
|
||||
`Supported formats: ${this.acceptedFormats.map((f) => `.${f}`).join(', ')}`
|
||||
);
|
||||
|
||||
// 숨겨진 파일 입력
|
||||
const fileInput = this.dropZone.createEl('input', {
|
||||
type: 'file',
|
||||
cls: 'drop-zone-input',
|
||||
cls: 'sn-drop-zone-input',
|
||||
});
|
||||
fileInput.accept = this.acceptedFormats.map((f) => `.${f}`).join(',');
|
||||
fileInput.multiple = true;
|
||||
|
|
@ -186,10 +190,10 @@ export class DragDropZone {
|
|||
|
||||
if (this.dropZone) {
|
||||
if (isDragging) {
|
||||
this.dropZone.addClass('dragging');
|
||||
this.dropZone.addClass('is-dragging');
|
||||
this.showDragOverlay();
|
||||
} else {
|
||||
this.dropZone.removeClass('dragging');
|
||||
this.dropZone.removeClass('is-dragging');
|
||||
this.hideDragOverlay();
|
||||
}
|
||||
}
|
||||
|
|
@ -201,27 +205,27 @@ export class DragDropZone {
|
|||
private showDragOverlay() {
|
||||
if (!this.dropZone) return;
|
||||
|
||||
const existingOverlay = this.dropZone.querySelector('.drag-overlay');
|
||||
const existingOverlay = this.dropZone.querySelector('.sn-drag-overlay');
|
||||
let overlay: HTMLElement;
|
||||
if (existingOverlay instanceof HTMLElement) {
|
||||
overlay = existingOverlay;
|
||||
} else {
|
||||
overlay = this.dropZone.createDiv('drag-overlay');
|
||||
const content = overlay.createDiv('drag-overlay-content');
|
||||
const icon = content.createDiv('drag-overlay-icon');
|
||||
overlay = this.dropZone.createDiv('sn-drag-overlay');
|
||||
const content = overlay.createDiv('sn-drag-overlay-content');
|
||||
const icon = content.createDiv('sn-drag-overlay-icon');
|
||||
icon.appendChild(this.createDropIcon());
|
||||
content.createDiv('drag-overlay-text').setText('파일을 여기에 놓으세요');
|
||||
content.createDiv('sn-drag-overlay-text').setText('Drop files to add them');
|
||||
}
|
||||
overlay.addClass('active');
|
||||
overlay.addClass('is-active');
|
||||
}
|
||||
|
||||
/**
|
||||
* 드래그 오버레이 숨기기
|
||||
*/
|
||||
private hideDragOverlay() {
|
||||
const overlay = this.dropZone?.querySelector('.drag-overlay');
|
||||
const overlay = this.dropZone?.querySelector('.sn-drag-overlay');
|
||||
if (overlay instanceof HTMLElement) {
|
||||
overlay.removeClass('active');
|
||||
overlay.removeClass('is-active');
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -288,7 +292,7 @@ export class DragDropZone {
|
|||
const validFiles = files.filter((file) => this.isValidFile(file));
|
||||
|
||||
if (validFiles.length === 0) {
|
||||
this.showError('유효한 오디오 파일이 없습니다');
|
||||
this.showError('No supported audio files were found.');
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -297,7 +301,7 @@ export class DragDropZone {
|
|||
}
|
||||
|
||||
// 시각적 피드백
|
||||
this.showSuccess(`${validFiles.length}개 파일 선택됨`);
|
||||
this.showSuccess(`${validFiles.length} file(s) selected`);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -320,7 +324,7 @@ export class DragDropZone {
|
|||
private showMessage(message: string, type: 'success' | 'error') {
|
||||
if (!this.dropZone) return;
|
||||
|
||||
const messageEl = this.dropZone.createDiv(`drop-zone-message ${type}`);
|
||||
const messageEl = this.dropZone.createDiv(`sn-drop-zone-message ${type}`);
|
||||
messageEl.setText(message);
|
||||
|
||||
setTimeout(() => {
|
||||
|
|
@ -338,55 +342,15 @@ export class DragDropZone {
|
|||
/**
|
||||
* 업로드 아이콘 SVG
|
||||
*/
|
||||
private createUploadIcon(): SVGElement {
|
||||
const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
|
||||
svg.setAttribute('width', '64');
|
||||
svg.setAttribute('height', '64');
|
||||
svg.setAttribute('viewBox', '0 0 64 64');
|
||||
svg.setAttribute('fill', 'none');
|
||||
|
||||
const paths = [
|
||||
'M42.667 42.667L32 32L21.333 42.667',
|
||||
'M32 32V56',
|
||||
'M54.373 46.373C56.871 43.875 58.667 40.617 58.667 37.333C58.667 30.707 53.293 25.333 46.667 25.333C45.827 25.333 45.013 25.44 44.24 25.64C41.795 18.747 35.488 13.333 28 13.333C18.427 13.333 10.667 21.093 10.667 30.667C10.667 31.947 10.827 33.187 11.12 34.373C6.88 35.92 4 39.947 4 44.667C4 50.56 8.773 55.333 14.667 55.333',
|
||||
];
|
||||
|
||||
paths.forEach((d) => {
|
||||
const path = document.createElementNS('http://www.w3.org/2000/svg', 'path');
|
||||
path.setAttribute('d', d);
|
||||
path.setAttribute('stroke', 'currentColor');
|
||||
path.setAttribute('stroke-width', '2');
|
||||
path.setAttribute('stroke-linecap', 'round');
|
||||
path.setAttribute('stroke-linejoin', 'round');
|
||||
svg.appendChild(path);
|
||||
});
|
||||
|
||||
return svg;
|
||||
private createUploadIcon(): HTMLElement {
|
||||
return createIconElement('upload');
|
||||
}
|
||||
|
||||
/**
|
||||
* 드롭 아이콘 SVG
|
||||
*/
|
||||
private createDropIcon(): SVGElement {
|
||||
const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
|
||||
svg.setAttribute('width', '48');
|
||||
svg.setAttribute('height', '48');
|
||||
svg.setAttribute('viewBox', '0 0 48 48');
|
||||
svg.setAttribute('fill', 'none');
|
||||
|
||||
const pathData = ['M8 30L24 14L40 30', 'M24 14V38'];
|
||||
|
||||
pathData.forEach((d) => {
|
||||
const path = document.createElementNS('http://www.w3.org/2000/svg', 'path');
|
||||
path.setAttribute('d', d);
|
||||
path.setAttribute('stroke', 'currentColor');
|
||||
path.setAttribute('stroke-width', '3');
|
||||
path.setAttribute('stroke-linecap', 'round');
|
||||
path.setAttribute('stroke-linejoin', 'round');
|
||||
svg.appendChild(path);
|
||||
});
|
||||
|
||||
return svg;
|
||||
private createDropIcon(): HTMLElement {
|
||||
return createIconElement('arrow-down-to-line');
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -396,12 +360,12 @@ export class DragDropZone {
|
|||
this.acceptedFormats = formats;
|
||||
|
||||
// UI 업데이트
|
||||
const formatsEl = this.dropZone?.querySelector('.drop-zone-formats');
|
||||
const formatsEl = this.dropZone?.querySelector('.sn-drop-zone-formats');
|
||||
if (formatsEl instanceof HTMLElement) {
|
||||
formatsEl.setText(`지원 형식: ${formats.map((f) => `.${f}`).join(', ')}`);
|
||||
formatsEl.setText(`Supported formats: ${formats.map((f) => `.${f}`).join(', ')}`);
|
||||
}
|
||||
|
||||
const input = this.dropZone?.querySelector('.drop-zone-input');
|
||||
const input = this.dropZone?.querySelector('.sn-drop-zone-input');
|
||||
if (input instanceof HTMLInputElement) {
|
||||
input.accept = formats.map((f) => `.${f}`).join(',');
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import { App, TFile } from 'obsidian';
|
||||
import { createIconElement } from '../../utils/common/helpers';
|
||||
|
||||
/**
|
||||
* 파일 브라우저 컴포넌트
|
||||
|
|
@ -44,7 +45,7 @@ export class FileBrowser {
|
|||
if (!this.container) return;
|
||||
|
||||
this.container.empty();
|
||||
this.container.addClass('file-browser');
|
||||
this.container.addClass('sn-file-browser');
|
||||
|
||||
// 툴바
|
||||
this.createToolbar();
|
||||
|
|
@ -59,14 +60,14 @@ export class FileBrowser {
|
|||
private createToolbar() {
|
||||
if (!this.container) return;
|
||||
|
||||
const toolbar = this.container.createDiv('file-browser-toolbar');
|
||||
const toolbar = this.container.createDiv('sn-file-browser-toolbar');
|
||||
|
||||
// 검색 박스
|
||||
const searchContainer = toolbar.createDiv('search-container');
|
||||
const searchContainer = toolbar.createDiv('sn-search-container');
|
||||
const searchInput = searchContainer.createEl('input', {
|
||||
type: 'text',
|
||||
placeholder: '파일 검색...',
|
||||
cls: 'search-input',
|
||||
placeholder: 'Search files...',
|
||||
cls: 'sn-search-input',
|
||||
});
|
||||
|
||||
searchInput.addEventListener('input', (e) => {
|
||||
|
|
@ -78,13 +79,13 @@ export class FileBrowser {
|
|||
});
|
||||
|
||||
// 정렬 옵션
|
||||
const sortContainer = toolbar.createDiv('sort-container');
|
||||
const sortContainer = toolbar.createDiv('sn-sort-container');
|
||||
|
||||
// 정렬 기준 선택
|
||||
const sortSelect = sortContainer.createEl('select', { cls: 'sort-select' });
|
||||
sortSelect.createEl('option', { value: 'name', text: '이름' });
|
||||
sortSelect.createEl('option', { value: 'date', text: '수정일' });
|
||||
sortSelect.createEl('option', { value: 'size', text: '크기' });
|
||||
const sortSelect = sortContainer.createEl('select', { cls: 'sn-sort-select' });
|
||||
sortSelect.createEl('option', { value: 'name', text: 'Name' });
|
||||
sortSelect.createEl('option', { value: 'date', text: 'Modified' });
|
||||
sortSelect.createEl('option', { value: 'size', text: 'Size' });
|
||||
sortSelect.value = this.sortBy;
|
||||
|
||||
sortSelect.addEventListener('change', (e) => {
|
||||
|
|
@ -100,8 +101,8 @@ export class FileBrowser {
|
|||
|
||||
// 정렬 순서 토글
|
||||
const sortOrderBtn = sortContainer.createEl('button', {
|
||||
cls: 'sort-order-btn',
|
||||
title: '정렬 순서 변경',
|
||||
cls: 'sn-sort-order-btn',
|
||||
title: 'Toggle sort order',
|
||||
});
|
||||
sortOrderBtn.setText(this.sortOrder === 'asc' ? '↑' : '↓');
|
||||
|
||||
|
|
@ -113,8 +114,8 @@ export class FileBrowser {
|
|||
|
||||
// 새로고침 버튼
|
||||
const refreshBtn = toolbar.createEl('button', {
|
||||
cls: 'refresh-btn',
|
||||
title: '새로고침',
|
||||
cls: 'sn-refresh-btn',
|
||||
title: 'Refresh file list',
|
||||
});
|
||||
refreshBtn.appendChild(this.createRefreshIcon());
|
||||
refreshBtn.addEventListener('click', () => this.render());
|
||||
|
|
@ -127,20 +128,20 @@ export class FileBrowser {
|
|||
if (!this.container) return;
|
||||
|
||||
// 기존 목록 제거
|
||||
const existingList = this.container.querySelector('.file-browser-list');
|
||||
const existingList = this.container.querySelector('.sn-file-browser-list');
|
||||
if (existingList) {
|
||||
existingList.remove();
|
||||
}
|
||||
|
||||
const listContainer = this.container.createDiv('file-browser-list');
|
||||
const listContainer = this.container.createDiv('sn-file-browser-list');
|
||||
|
||||
// 오디오 파일 가져오기
|
||||
const audioFiles = this.getAudioFiles();
|
||||
|
||||
if (audioFiles.length === 0) {
|
||||
listContainer.createDiv({
|
||||
cls: 'empty-state',
|
||||
text: '오디오 파일이 없습니다',
|
||||
cls: 'sn-empty-state',
|
||||
text: 'No audio files found.',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
|
@ -153,23 +154,23 @@ export class FileBrowser {
|
|||
if (files.length === 0) return;
|
||||
|
||||
// 폴더 헤더
|
||||
const folderHeader = listContainer.createDiv('folder-header');
|
||||
const folderHeader = listContainer.createDiv('sn-folder-header');
|
||||
folderHeader.createEl('span', {
|
||||
cls: 'folder-icon',
|
||||
cls: 'sn-folder-icon',
|
||||
text: '📁',
|
||||
});
|
||||
folderHeader.createEl('span', {
|
||||
cls: 'folder-name',
|
||||
cls: 'sn-folder-name',
|
||||
text: folderPath || 'Root',
|
||||
});
|
||||
folderHeader.createEl('span', {
|
||||
cls: 'folder-count',
|
||||
cls: 'sn-folder-count',
|
||||
text: `(${files.length})`,
|
||||
});
|
||||
|
||||
// 폴더 토글
|
||||
let isExpanded = true;
|
||||
const fileList = listContainer.createDiv('folder-files');
|
||||
const fileList = listContainer.createDiv('sn-folder-files');
|
||||
|
||||
folderHeader.addEventListener('click', () => {
|
||||
isExpanded = !isExpanded;
|
||||
|
|
@ -178,7 +179,7 @@ export class FileBrowser {
|
|||
folderHeader.removeClass('collapsed');
|
||||
} else {
|
||||
fileList.hide();
|
||||
folderHeader.addClass('collapsed');
|
||||
folderHeader.addClass('is-collapsed');
|
||||
}
|
||||
});
|
||||
|
||||
|
|
@ -193,43 +194,43 @@ export class FileBrowser {
|
|||
* 파일 아이템 생성
|
||||
*/
|
||||
private createFileItem(container: HTMLElement, file: TFile) {
|
||||
const fileItem = container.createDiv('file-item');
|
||||
const fileItem = container.createDiv('sn-file-item');
|
||||
|
||||
// 파일 아이콘
|
||||
const icon = fileItem.createDiv('file-icon');
|
||||
const icon = fileItem.createDiv('sn-file-icon');
|
||||
icon.setText(this.getFileIcon(file.extension));
|
||||
|
||||
// 파일 정보
|
||||
const fileInfo = fileItem.createDiv('file-info');
|
||||
const fileInfo = fileItem.createDiv('sn-file-info');
|
||||
|
||||
// 파일명
|
||||
const fileName = fileInfo.createDiv('file-name');
|
||||
const fileName = fileInfo.createDiv('sn-file-name');
|
||||
fileName.setText(file.basename);
|
||||
|
||||
// 파일 메타데이터
|
||||
const fileMeta = fileInfo.createDiv('file-meta');
|
||||
const fileMeta = fileInfo.createDiv('sn-file-meta');
|
||||
fileMeta.createEl('span', {
|
||||
cls: 'file-size',
|
||||
cls: 'sn-file-size',
|
||||
text: this.formatFileSize(file.stat.size),
|
||||
});
|
||||
fileMeta.createEl('span', {
|
||||
cls: 'file-date',
|
||||
cls: 'sn-file-date',
|
||||
text: this.formatDate(file.stat.mtime),
|
||||
});
|
||||
fileMeta.createEl('span', {
|
||||
cls: 'file-ext',
|
||||
cls: 'sn-file-ext',
|
||||
text: `.${file.extension}`,
|
||||
});
|
||||
|
||||
// 클릭 이벤트
|
||||
fileItem.addEventListener('click', () => {
|
||||
this.selectFile(file);
|
||||
fileItem.addClass('selected');
|
||||
fileItem.addClass('is-selected');
|
||||
|
||||
// 다른 선택 해제
|
||||
container.querySelectorAll('.file-item').forEach((item) => {
|
||||
container.querySelectorAll('.sn-file-item').forEach((item) => {
|
||||
if (item !== fileItem) {
|
||||
item.removeClass('selected');
|
||||
item.removeClass('is-selected');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
@ -293,10 +294,12 @@ export class FileBrowser {
|
|||
|
||||
files.forEach((file) => {
|
||||
const folderPath = file.parent?.path || '';
|
||||
if (!grouped.has(folderPath)) {
|
||||
grouped.set(folderPath, []);
|
||||
const existingFiles = grouped.get(folderPath);
|
||||
if (existingFiles) {
|
||||
existingFiles.push(file);
|
||||
} else {
|
||||
grouped.set(folderPath, [file]);
|
||||
}
|
||||
grouped.get(folderPath)!.push(file);
|
||||
});
|
||||
|
||||
// 폴더 경로로 정렬
|
||||
|
|
@ -341,21 +344,21 @@ export class FileBrowser {
|
|||
if (hours === 0) {
|
||||
const minutes = Math.floor(diff / (1000 * 60));
|
||||
if (minutes === 0) {
|
||||
return '방금 전';
|
||||
return 'Just now';
|
||||
}
|
||||
return `${minutes}분 전`;
|
||||
return `${minutes} min ago`;
|
||||
}
|
||||
return `${hours}시간 전`;
|
||||
return `${hours} hr ago`;
|
||||
} else if (days === 1) {
|
||||
return '어제';
|
||||
return 'Yesterday';
|
||||
} else if (days < 7) {
|
||||
return `${days}일 전`;
|
||||
return `${days} days ago`;
|
||||
} else if (days < 30) {
|
||||
const weeks = Math.floor(days / 7);
|
||||
return `${weeks}주 전`;
|
||||
return `${weeks} weeks ago`;
|
||||
} else if (days < 365) {
|
||||
const months = Math.floor(days / 30);
|
||||
return `${months}개월 전`;
|
||||
return `${months} months ago`;
|
||||
} else {
|
||||
return date.toLocaleDateString();
|
||||
}
|
||||
|
|
@ -380,21 +383,7 @@ export class FileBrowser {
|
|||
/**
|
||||
* 새로고침 아이콘
|
||||
*/
|
||||
private createRefreshIcon(): SVGElement {
|
||||
const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
|
||||
svg.setAttribute('width', '16');
|
||||
svg.setAttribute('height', '16');
|
||||
svg.setAttribute('viewBox', '0 0 16 16');
|
||||
svg.setAttribute('fill', 'none');
|
||||
|
||||
const path = document.createElementNS('http://www.w3.org/2000/svg', 'path');
|
||||
path.setAttribute(
|
||||
'd',
|
||||
'M13.65 2.35C12.2 0.9 10.21 0 8 0C3.58 0 0 3.58 0 8C0 12.42 3.58 16 8 16C11.73 16 14.84 13.45 15.73 10H13.65C12.83 12.33 10.61 14 8 14C4.69 14 2 11.31 2 8C2 4.69 4.69 2 8 2C9.66 2 11.14 2.69 12.22 3.78L9 7H16V0L13.65 2.35Z'
|
||||
);
|
||||
path.setAttribute('fill', 'currentColor');
|
||||
svg.appendChild(path);
|
||||
|
||||
return svg;
|
||||
private createRefreshIcon(): HTMLElement {
|
||||
return createIconElement('refresh-cw');
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,3 +1,5 @@
|
|||
import { createIconElement } from '../../utils/common/helpers';
|
||||
|
||||
/**
|
||||
* 진행 상태 표시 컴포넌트
|
||||
* - 로딩 스피너
|
||||
|
|
@ -37,39 +39,39 @@ export class ProgressIndicator {
|
|||
private createProgressElement() {
|
||||
if (!this.container) return;
|
||||
|
||||
this.progressElement = this.container.createDiv('progress-indicator');
|
||||
this.progressElement = this.container.createDiv('sn-progress-indicator');
|
||||
this.progressElement.addClass('sn-hidden');
|
||||
this.progressElement.addClass('sn-fade');
|
||||
this.progressElement.addClass('sn-fade-hidden');
|
||||
|
||||
// 오버레이
|
||||
this.progressElement.createDiv('progress-overlay');
|
||||
this.progressElement.createDiv('sn-progress-overlay');
|
||||
|
||||
// 컨테이너
|
||||
const content = this.progressElement.createDiv('progress-content');
|
||||
const content = this.progressElement.createDiv('sn-progress-content');
|
||||
|
||||
// 스피너
|
||||
const spinner = content.createDiv('progress-spinner');
|
||||
spinner.appendChild(this.getSpinnerSVG());
|
||||
const spinner = content.createDiv('sn-progress-spinner');
|
||||
spinner.appendChild(this.getSpinnerElement());
|
||||
|
||||
// 진행률 바 컨테이너
|
||||
const barContainer = content.createDiv('progress-bar-container');
|
||||
const barContainer = content.createDiv('sn-progress-bar-container');
|
||||
barContainer.addClass('sn-hidden');
|
||||
|
||||
// 진행률 바
|
||||
const progressBar = barContainer.createDiv('progress-bar');
|
||||
progressBar.createDiv('progress-fill');
|
||||
const progressText = progressBar.createDiv('progress-text');
|
||||
const progressBar = barContainer.createDiv('sn-progress-bar');
|
||||
progressBar.createDiv('sn-progress-fill');
|
||||
const progressText = progressBar.createDiv('sn-progress-text');
|
||||
progressText.setText('0%');
|
||||
|
||||
// 메시지
|
||||
const message = content.createDiv('progress-message');
|
||||
message.setText('처리 중...');
|
||||
const message = content.createDiv('sn-progress-message');
|
||||
message.setText('Processing...');
|
||||
|
||||
// 취소 버튼
|
||||
const cancelBtn = content.createEl('button', {
|
||||
cls: 'progress-cancel-btn',
|
||||
text: '취소',
|
||||
cls: 'sn-progress-cancel-btn',
|
||||
text: 'Cancel',
|
||||
});
|
||||
cancelBtn.addClass('sn-hidden');
|
||||
cancelBtn.addEventListener('click', () => {
|
||||
|
|
@ -95,15 +97,15 @@ export class ProgressIndicator {
|
|||
|
||||
// 메시지 설정
|
||||
if (message) {
|
||||
const messageEl = this.progressElement.querySelector('.progress-message');
|
||||
if (messageEl instanceof HTMLElement) {
|
||||
messageEl.setText(message);
|
||||
const namespacedMessageEl = this.progressElement.querySelector('.sn-progress-message');
|
||||
if (namespacedMessageEl instanceof HTMLElement) {
|
||||
namespacedMessageEl.setText(message);
|
||||
}
|
||||
}
|
||||
|
||||
// 진행률 바 표시/숨김
|
||||
const barContainer = this.progressElement.querySelector('.progress-bar-container');
|
||||
const spinner = this.progressElement.querySelector('.progress-spinner');
|
||||
const barContainer = this.progressElement.querySelector('.sn-progress-bar-container');
|
||||
const spinner = this.progressElement.querySelector('.sn-progress-spinner');
|
||||
|
||||
if (showProgressBar) {
|
||||
if (barContainer instanceof HTMLElement) {
|
||||
|
|
@ -125,7 +127,7 @@ export class ProgressIndicator {
|
|||
}
|
||||
|
||||
// 취소 버튼 표시/숨김
|
||||
const cancelBtn = this.progressElement.querySelector('.progress-cancel-btn');
|
||||
const cancelBtn = this.progressElement.querySelector('.sn-progress-cancel-btn');
|
||||
if (cancelBtn instanceof HTMLElement) {
|
||||
cancelBtn.toggleClass('sn-hidden', !cancellable);
|
||||
}
|
||||
|
|
@ -161,8 +163,8 @@ export class ProgressIndicator {
|
|||
this.currentProgress = Math.min(100, Math.max(0, progress));
|
||||
|
||||
// 진행률 바 업데이트
|
||||
const progressFill = this.progressElement.querySelector('.progress-fill');
|
||||
const progressText = this.progressElement.querySelector('.progress-text');
|
||||
const progressFill = this.progressElement.querySelector('.sn-progress-fill');
|
||||
const progressText = this.progressElement.querySelector('.sn-progress-text');
|
||||
|
||||
if (progressFill instanceof HTMLElement && progressText instanceof HTMLElement) {
|
||||
progressFill.setAttribute('style', `--sn-progress-width:${this.currentProgress}%`);
|
||||
|
|
@ -170,19 +172,19 @@ export class ProgressIndicator {
|
|||
|
||||
// 진행률에 따른 색상 변경
|
||||
if (this.currentProgress < 30) {
|
||||
progressFill.removeClass('progress-warning', 'progress-success');
|
||||
progressFill.removeClass('is-warning', 'is-success');
|
||||
} else if (this.currentProgress < 70) {
|
||||
progressFill.addClass('progress-warning');
|
||||
progressFill.removeClass('progress-success');
|
||||
progressFill.addClass('is-warning');
|
||||
progressFill.removeClass('is-success');
|
||||
} else {
|
||||
progressFill.removeClass('progress-warning');
|
||||
progressFill.addClass('progress-success');
|
||||
progressFill.removeClass('is-warning');
|
||||
progressFill.addClass('is-success');
|
||||
}
|
||||
}
|
||||
|
||||
// 메시지 업데이트
|
||||
if (message) {
|
||||
const messageEl = this.progressElement.querySelector('.progress-message');
|
||||
const messageEl = this.progressElement.querySelector('.sn-progress-message');
|
||||
if (messageEl instanceof HTMLElement) {
|
||||
messageEl.setText(message);
|
||||
}
|
||||
|
|
@ -223,7 +225,7 @@ export class ProgressIndicator {
|
|||
setMessage(message: string) {
|
||||
if (!this.progressElement) return;
|
||||
|
||||
const messageEl = this.progressElement.querySelector('.progress-message');
|
||||
const messageEl = this.progressElement.querySelector('.sn-progress-message');
|
||||
if (messageEl instanceof HTMLElement) {
|
||||
messageEl.setText(message);
|
||||
}
|
||||
|
|
@ -237,12 +239,12 @@ export class ProgressIndicator {
|
|||
|
||||
this.show(message, false, false);
|
||||
|
||||
const content = this.progressElement.querySelector('.progress-content');
|
||||
const content = this.progressElement.querySelector('.sn-progress-content');
|
||||
if (content instanceof HTMLElement) {
|
||||
content.addClass('error');
|
||||
content.addClass('is-error');
|
||||
|
||||
// 스피너를 에러 아이콘으로 변경
|
||||
const spinner = content.querySelector('.progress-spinner');
|
||||
const spinner = content.querySelector('.sn-progress-spinner');
|
||||
if (spinner instanceof HTMLElement) {
|
||||
spinner.replaceChildren(this.getErrorIcon());
|
||||
}
|
||||
|
|
@ -260,12 +262,12 @@ export class ProgressIndicator {
|
|||
|
||||
this.show(message, false, false);
|
||||
|
||||
const content = this.progressElement.querySelector('.progress-content');
|
||||
const content = this.progressElement.querySelector('.sn-progress-content');
|
||||
if (content instanceof HTMLElement) {
|
||||
content.addClass('success');
|
||||
content.addClass('is-success');
|
||||
|
||||
// 스피너를 성공 아이콘으로 변경
|
||||
const spinner = content.querySelector('.progress-spinner');
|
||||
const spinner = content.querySelector('.sn-progress-spinner');
|
||||
if (spinner instanceof HTMLElement) {
|
||||
spinner.replaceChildren(this.getSuccessIcon());
|
||||
}
|
||||
|
|
@ -286,7 +288,7 @@ export class ProgressIndicator {
|
|||
* 스피너 애니메이션 시작
|
||||
*/
|
||||
private startSpinnerAnimation() {
|
||||
const spinner = this.progressElement?.querySelector('.progress-spinner');
|
||||
const spinner = this.progressElement?.querySelector('.sn-progress-spinner');
|
||||
if (spinner instanceof HTMLElement) {
|
||||
spinner.addClass('is-spinning');
|
||||
}
|
||||
|
|
@ -296,7 +298,7 @@ export class ProgressIndicator {
|
|||
* 스피너 애니메이션 중지
|
||||
*/
|
||||
private stopSpinnerAnimation() {
|
||||
const spinner = this.progressElement?.querySelector('.progress-spinner');
|
||||
const spinner = this.progressElement?.querySelector('.sn-progress-spinner');
|
||||
if (spinner instanceof HTMLElement) {
|
||||
spinner.removeClass('is-spinning');
|
||||
}
|
||||
|
|
@ -333,95 +335,24 @@ export class ProgressIndicator {
|
|||
/**
|
||||
* 스피너 SVG
|
||||
*/
|
||||
private getSpinnerSVG(): SVGElement {
|
||||
const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
|
||||
svg.setAttribute('width', '40');
|
||||
svg.setAttribute('height', '40');
|
||||
svg.setAttribute('viewBox', '0 0 40 40');
|
||||
|
||||
const background = document.createElementNS('http://www.w3.org/2000/svg', 'circle');
|
||||
background.setAttribute('cx', '20');
|
||||
background.setAttribute('cy', '20');
|
||||
background.setAttribute('r', '18');
|
||||
background.setAttribute('stroke', 'currentColor');
|
||||
background.setAttribute('stroke-width', '3');
|
||||
background.setAttribute('fill', 'none');
|
||||
background.setAttribute('opacity', '0.2');
|
||||
svg.appendChild(background);
|
||||
|
||||
const arc = document.createElementNS('http://www.w3.org/2000/svg', 'circle');
|
||||
arc.setAttribute('cx', '20');
|
||||
arc.setAttribute('cy', '20');
|
||||
arc.setAttribute('r', '18');
|
||||
arc.setAttribute('stroke', 'currentColor');
|
||||
arc.setAttribute('stroke-width', '3');
|
||||
arc.setAttribute('fill', 'none');
|
||||
arc.setAttribute('stroke-dasharray', '80');
|
||||
arc.setAttribute('stroke-dashoffset', '60');
|
||||
arc.setAttribute('stroke-linecap', 'round');
|
||||
svg.appendChild(arc);
|
||||
|
||||
return svg;
|
||||
private getSpinnerElement(): HTMLElement {
|
||||
const spinner = createEl('div', { cls: 'sn-progress-spinner__glyph' });
|
||||
spinner.setAttribute('aria-hidden', 'true');
|
||||
return spinner;
|
||||
}
|
||||
|
||||
/**
|
||||
* 성공 아이콘
|
||||
*/
|
||||
private getSuccessIcon(): SVGElement {
|
||||
const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
|
||||
svg.setAttribute('width', '40');
|
||||
svg.setAttribute('height', '40');
|
||||
svg.setAttribute('viewBox', '0 0 40 40');
|
||||
|
||||
const circle = document.createElementNS('http://www.w3.org/2000/svg', 'circle');
|
||||
circle.setAttribute('cx', '20');
|
||||
circle.setAttribute('cy', '20');
|
||||
circle.setAttribute('r', '18');
|
||||
circle.setAttribute('stroke', 'currentColor');
|
||||
circle.setAttribute('stroke-width', '3');
|
||||
circle.setAttribute('fill', 'none');
|
||||
svg.appendChild(circle);
|
||||
|
||||
const path = document.createElementNS('http://www.w3.org/2000/svg', 'path');
|
||||
path.setAttribute('d', 'M12 20L17 25L28 14');
|
||||
path.setAttribute('stroke', 'currentColor');
|
||||
path.setAttribute('stroke-width', '3');
|
||||
path.setAttribute('stroke-linecap', 'round');
|
||||
path.setAttribute('stroke-linejoin', 'round');
|
||||
path.setAttribute('fill', 'none');
|
||||
svg.appendChild(path);
|
||||
|
||||
return svg;
|
||||
private getSuccessIcon(): HTMLElement {
|
||||
return createIconElement('check');
|
||||
}
|
||||
|
||||
/**
|
||||
* 에러 아이콘
|
||||
*/
|
||||
private getErrorIcon(): SVGElement {
|
||||
const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
|
||||
svg.setAttribute('width', '40');
|
||||
svg.setAttribute('height', '40');
|
||||
svg.setAttribute('viewBox', '0 0 40 40');
|
||||
|
||||
const circle = document.createElementNS('http://www.w3.org/2000/svg', 'circle');
|
||||
circle.setAttribute('cx', '20');
|
||||
circle.setAttribute('cy', '20');
|
||||
circle.setAttribute('r', '18');
|
||||
circle.setAttribute('stroke', 'currentColor');
|
||||
circle.setAttribute('stroke-width', '3');
|
||||
circle.setAttribute('fill', 'none');
|
||||
svg.appendChild(circle);
|
||||
|
||||
const path = document.createElementNS('http://www.w3.org/2000/svg', 'path');
|
||||
path.setAttribute('d', 'M14 14L26 26M26 14L14 26');
|
||||
path.setAttribute('stroke', 'currentColor');
|
||||
path.setAttribute('stroke-width', '3');
|
||||
path.setAttribute('stroke-linecap', 'round');
|
||||
path.setAttribute('stroke-linejoin', 'round');
|
||||
path.setAttribute('fill', 'none');
|
||||
svg.appendChild(path);
|
||||
|
||||
return svg;
|
||||
private getErrorIcon(): HTMLElement {
|
||||
return createIconElement('x');
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -47,7 +47,7 @@ export class RecentFiles {
|
|||
if (!this.container) return;
|
||||
|
||||
this.container.empty();
|
||||
this.container.addClass('recent-files');
|
||||
this.container.addClass('sn-recent-files');
|
||||
|
||||
// 헤더
|
||||
const header = this.container.createDiv('recent-files-header');
|
||||
|
|
@ -74,12 +74,12 @@ export class RecentFiles {
|
|||
if (!this.container) return;
|
||||
|
||||
// 기존 목록 제거
|
||||
const existingList = this.container.querySelector('.recent-files-list');
|
||||
const existingList = this.container.querySelector('.sn-recent-files-list');
|
||||
if (existingList) {
|
||||
existingList.remove();
|
||||
}
|
||||
|
||||
const listContainer = this.container.createDiv('recent-files-list');
|
||||
const listContainer = this.container.createDiv('sn-recent-files-list');
|
||||
|
||||
// 유효한 파일만 필터링
|
||||
const validFiles = this.getValidRecentFiles();
|
||||
|
|
@ -110,30 +110,30 @@ export class RecentFiles {
|
|||
entry: RecentFileEntry,
|
||||
order: number
|
||||
) {
|
||||
const fileItem = container.createDiv('recent-file-item');
|
||||
const fileItem = container.createDiv('sn-recent-file-item');
|
||||
|
||||
// 순서 번호
|
||||
const orderBadge = fileItem.createDiv('file-order');
|
||||
const orderBadge = fileItem.createDiv('sn-file-order');
|
||||
orderBadge.setText(order.toString());
|
||||
|
||||
// 파일 아이콘
|
||||
const icon = fileItem.createDiv('file-icon');
|
||||
const icon = fileItem.createDiv('sn-file-icon');
|
||||
icon.setText(this.getFileIcon(file.extension));
|
||||
|
||||
// 파일 정보
|
||||
const fileInfo = fileItem.createDiv('file-info');
|
||||
const fileInfo = fileItem.createDiv('sn-file-info');
|
||||
|
||||
// 파일명
|
||||
const fileName = fileInfo.createDiv('file-name');
|
||||
const fileName = fileInfo.createDiv('sn-file-name');
|
||||
fileName.setText(file.basename);
|
||||
|
||||
// 파일 경로와 시간
|
||||
const fileMeta = fileInfo.createDiv('file-meta');
|
||||
const fileMeta = fileInfo.createDiv('sn-file-meta');
|
||||
|
||||
// 경로 (폴더명만)
|
||||
if (file.parent) {
|
||||
fileMeta.createEl('span', {
|
||||
cls: 'file-path',
|
||||
cls: 'sn-file-path',
|
||||
text: file.parent.path,
|
||||
title: file.path,
|
||||
});
|
||||
|
|
@ -141,18 +141,18 @@ export class RecentFiles {
|
|||
|
||||
// 사용 시간
|
||||
fileMeta.createEl('span', {
|
||||
cls: 'file-time',
|
||||
cls: 'sn-file-time',
|
||||
text: this.formatRelativeTime(entry.timestamp),
|
||||
});
|
||||
|
||||
// 파일 크기
|
||||
fileMeta.createEl('span', {
|
||||
cls: 'file-size',
|
||||
cls: 'sn-file-size',
|
||||
text: this.formatFileSize(file.stat.size),
|
||||
});
|
||||
|
||||
// 액션 버튼들
|
||||
const actions = fileItem.createDiv('file-actions');
|
||||
const actions = fileItem.createDiv('sn-file-actions');
|
||||
|
||||
// 선택 버튼
|
||||
const selectBtn = actions.createEl('button', {
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@
|
|||
|
||||
import type { App } from 'obsidian';
|
||||
import { EventManager } from '../../application/EventManager';
|
||||
import { safeJsonParse } from '../../utils/common/helpers';
|
||||
import { ConfirmationModal } from '../modals/ConfirmationModal';
|
||||
import { Notice } from 'obsidian';
|
||||
|
||||
|
|
@ -235,7 +236,7 @@ export class StatisticsDashboard {
|
|||
* 대시보드 생성
|
||||
*/
|
||||
create(container: HTMLElement): HTMLElement {
|
||||
this.element = createEl('div', { cls: 'statistics-dashboard' });
|
||||
this.element = createEl('div', { cls: 'sn-statistics-dashboard' });
|
||||
this.element.setAttribute('role', 'region');
|
||||
this.element.setAttribute('aria-label', 'Statistics dashboard');
|
||||
|
||||
|
|
@ -273,12 +274,12 @@ export class StatisticsDashboard {
|
|||
* 헤더 생성
|
||||
*/
|
||||
private createHeader(): HTMLElement {
|
||||
const header = createEl('div', { cls: 'dashboard__header' });
|
||||
const header = createEl('div', { cls: 'sn-dashboard__header' });
|
||||
|
||||
const title = createEl('h2', { text: 'Transcription statistics' });
|
||||
header.appendChild(title);
|
||||
|
||||
const controls = createEl('div', { cls: 'dashboard__controls' });
|
||||
const controls = createEl('div', { cls: 'sn-dashboard__controls' });
|
||||
|
||||
// 새로고침 버튼
|
||||
const refreshBtn = createEl('button', { cls: 'dashboard__refresh', text: 'Refresh' });
|
||||
|
|
@ -304,7 +305,7 @@ export class StatisticsDashboard {
|
|||
* 통계 그리드 생성
|
||||
*/
|
||||
private createStatsGrid(): HTMLElement {
|
||||
const grid = createEl('div', { cls: 'dashboard__stats-grid' });
|
||||
const grid = createEl('div', { cls: 'sn-dashboard__stats-grid' });
|
||||
|
||||
// 통계 카드들
|
||||
const cards = [
|
||||
|
|
@ -320,19 +321,21 @@ export class StatisticsDashboard {
|
|||
|
||||
cards.forEach((card) => {
|
||||
const cardEl = createEl('div', {
|
||||
cls: card.color ? ['stats-card', `stats-card--${card.color}`] : 'stats-card',
|
||||
cls: card.color
|
||||
? ['sn-stats-card', `sn-stats-card--${card.color}`]
|
||||
: 'sn-stats-card',
|
||||
});
|
||||
cardEl.setAttribute('data-stat-id', card.id);
|
||||
|
||||
const icon = createEl('div', { cls: 'stats-card__icon', text: card.icon });
|
||||
const icon = createEl('div', { cls: 'sn-stats-card__icon', text: card.icon });
|
||||
cardEl.appendChild(icon);
|
||||
|
||||
const content = createEl('div', { cls: 'stats-card__content' });
|
||||
const content = createEl('div', { cls: 'sn-stats-card__content' });
|
||||
|
||||
const value = createEl('div', { cls: 'stats-card__value', text: card.value });
|
||||
const value = createEl('div', { cls: 'sn-stats-card__value', text: card.value });
|
||||
content.appendChild(value);
|
||||
|
||||
const label = createEl('div', { cls: 'stats-card__label', text: card.label });
|
||||
const label = createEl('div', { cls: 'sn-stats-card__label', text: card.label });
|
||||
content.appendChild(label);
|
||||
|
||||
cardEl.appendChild(content);
|
||||
|
|
@ -346,27 +349,27 @@ export class StatisticsDashboard {
|
|||
* 차트 영역 생성
|
||||
*/
|
||||
private createCharts(): HTMLElement {
|
||||
const charts = createEl('div', { cls: 'dashboard__charts' });
|
||||
const charts = createEl('div', { cls: 'sn-dashboard__charts' });
|
||||
|
||||
// 시간대별 차트
|
||||
const timeChart = createEl('div', { cls: 'chart-container' });
|
||||
const timeChart = createEl('div', { cls: 'sn-chart-container' });
|
||||
|
||||
const timeChartTitle = createEl('h3', { text: 'Transcriptions by hour' });
|
||||
timeChart.appendChild(timeChartTitle);
|
||||
|
||||
const timeChartCanvas = createEl('div', { cls: 'chart-canvas' });
|
||||
const timeChartCanvas = createEl('div', { cls: 'sn-chart-canvas' });
|
||||
timeChartCanvas.id = 'time-chart';
|
||||
timeChart.appendChild(timeChartCanvas);
|
||||
|
||||
charts.appendChild(timeChart);
|
||||
|
||||
// 성공률 차트
|
||||
const successChart = createEl('div', { cls: 'chart-container' });
|
||||
const successChart = createEl('div', { cls: 'sn-chart-container' });
|
||||
|
||||
const successChartTitle = createEl('h3', { text: 'Success rate trend' });
|
||||
successChart.appendChild(successChartTitle);
|
||||
|
||||
const successChartCanvas = createEl('div', { cls: 'chart-canvas' });
|
||||
const successChartCanvas = createEl('div', { cls: 'sn-chart-canvas' });
|
||||
successChartCanvas.id = 'success-chart';
|
||||
successChart.appendChild(successChartCanvas);
|
||||
|
||||
|
|
@ -379,15 +382,15 @@ export class StatisticsDashboard {
|
|||
* 히스토리 테이블 생성
|
||||
*/
|
||||
private createHistoryTable(): HTMLElement {
|
||||
const container = createEl('div', { cls: 'dashboard__history' });
|
||||
const container = createEl('div', { cls: 'sn-dashboard__history' });
|
||||
|
||||
const header = createEl('div', { cls: 'history__header' });
|
||||
const header = createEl('div', { cls: 'sn-history__header' });
|
||||
|
||||
const title = createEl('h3', { text: 'Recent transcriptions' });
|
||||
header.appendChild(title);
|
||||
|
||||
// 필터
|
||||
const filter = createEl('select', { cls: 'history__filter' });
|
||||
const filter = createEl('select', { cls: 'sn-history__filter' });
|
||||
|
||||
const filterOptions = [
|
||||
{ value: 'all', label: 'All' },
|
||||
|
|
@ -408,7 +411,7 @@ export class StatisticsDashboard {
|
|||
container.appendChild(header);
|
||||
|
||||
// 테이블
|
||||
const table = createEl('table', { cls: 'history__table' });
|
||||
const table = createEl('table', { cls: 'sn-history__table' });
|
||||
|
||||
const thead = createEl('thead');
|
||||
const headerRow = createEl('tr');
|
||||
|
|
@ -419,7 +422,7 @@ export class StatisticsDashboard {
|
|||
thead.appendChild(headerRow);
|
||||
table.appendChild(thead);
|
||||
|
||||
const tbody = createEl('tbody', { cls: 'history__tbody' });
|
||||
const tbody = createEl('tbody', { cls: 'sn-history__tbody' });
|
||||
table.appendChild(tbody);
|
||||
|
||||
container.appendChild(table);
|
||||
|
|
@ -491,14 +494,17 @@ export class StatisticsDashboard {
|
|||
const maxCount = hourCounts.reduce((max, count) => Math.max(max, count), 1);
|
||||
|
||||
chartEl.replaceChildren();
|
||||
const chartContainer = createEl('div', { cls: 'bar-chart' });
|
||||
const chartContainer = createEl('div', { cls: 'sn-bar-chart' });
|
||||
|
||||
hourCounts.forEach((count, hour) => {
|
||||
const bar = createEl('div', { cls: 'bar-chart__bar' });
|
||||
const bar = createEl('div', { cls: 'sn-bar-chart__bar' });
|
||||
const height = (count / maxCount) * 100;
|
||||
bar.setAttribute('style', `--sn-bar-height:${height}%`);
|
||||
|
||||
const valueLabel = createEl('span', { cls: 'bar-chart__value', text: String(count) });
|
||||
const valueLabel = createEl('span', {
|
||||
cls: 'sn-bar-chart__value',
|
||||
text: String(count),
|
||||
});
|
||||
bar.appendChild(valueLabel);
|
||||
|
||||
const barLabel = createEl('span', { cls: 'bar-chart__label', text: `${hour}:00` });
|
||||
|
|
@ -535,22 +541,22 @@ export class StatisticsDashboard {
|
|||
const dates = Object.keys(dailyStats).slice(-7);
|
||||
|
||||
chartEl.replaceChildren();
|
||||
const lineChart = createEl('div', { cls: 'line-chart' });
|
||||
const lineChart = createEl('div', { cls: 'sn-line-chart' });
|
||||
|
||||
dates.forEach((date) => {
|
||||
const stat = dailyStats[date];
|
||||
const rate = stat.total > 0 ? (stat.success / stat.total) * 100 : 0;
|
||||
|
||||
const point = createEl('div', { cls: 'line-chart__point' });
|
||||
const point = createEl('div', { cls: 'sn-line-chart__point' });
|
||||
|
||||
const valueLabel = createEl('span', {
|
||||
cls: 'line-chart__value',
|
||||
cls: 'sn-line-chart__value',
|
||||
text: `${rate.toFixed(0)}%`,
|
||||
});
|
||||
point.appendChild(valueLabel);
|
||||
|
||||
const dateLabel = createEl('span', {
|
||||
cls: 'line-chart__label',
|
||||
cls: 'sn-line-chart__label',
|
||||
text: date.split('/').slice(0, 2).join('/'),
|
||||
});
|
||||
point.appendChild(dateLabel);
|
||||
|
|
@ -565,7 +571,7 @@ export class StatisticsDashboard {
|
|||
* 히스토리 테이블 업데이트
|
||||
*/
|
||||
private updateHistoryTable(filter = 'all') {
|
||||
const tbody = this.element?.querySelector('.history__tbody');
|
||||
const tbody = this.element?.querySelector('.sn-history__tbody');
|
||||
if (!tbody) return;
|
||||
|
||||
let records = StatisticsStore.getAllRecords();
|
||||
|
|
@ -607,7 +613,7 @@ export class StatisticsDashboard {
|
|||
|
||||
const statusCell = createEl('td');
|
||||
const statusBadge = createEl('span', {
|
||||
cls: ['status', `status--${record.status}`],
|
||||
cls: ['sn-status', `sn-status--${record.status}`],
|
||||
text: this.getStatusText(record.status),
|
||||
});
|
||||
statusCell.appendChild(statusBadge);
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ import {
|
|||
} from '../../types/phase3-api';
|
||||
import { SimpleEventEmitter as EventEmitter } from '../../utils/SimpleEventEmitter';
|
||||
import { EventManager } from '../../application/EventManager';
|
||||
import { createIconElement } from '../../utils/common/helpers';
|
||||
|
||||
/**
|
||||
* 알림 채널 인터페이스
|
||||
|
|
@ -244,7 +245,7 @@ class ToastChannel implements NotificationChannel {
|
|||
if (!DOM_AVAILABLE) {
|
||||
return;
|
||||
}
|
||||
const container = createEl('div', { cls: 'toast-container' });
|
||||
const container = createEl('div', { cls: 'sn-toast-container' });
|
||||
container.setAttribute('aria-live', 'polite');
|
||||
container.setAttribute('aria-atomic', 'true');
|
||||
this.container = container;
|
||||
|
|
@ -255,7 +256,7 @@ class ToastChannel implements NotificationChannel {
|
|||
setPosition(position: NotificationPosition): void {
|
||||
this.position = position;
|
||||
if (this.container) {
|
||||
this.container.className = `toast-container toast-container--${position}`;
|
||||
this.container.className = `sn-toast-container sn-toast-container--${position}`;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -276,10 +277,10 @@ class ToastChannel implements NotificationChannel {
|
|||
|
||||
if (typeof requestAnimationFrame === 'function') {
|
||||
requestAnimationFrame(() => {
|
||||
toast.classList.add('toast--show');
|
||||
toast.classList.add('sn-toast--show');
|
||||
});
|
||||
} else {
|
||||
toast.classList.add('toast--show');
|
||||
toast.classList.add('sn-toast--show');
|
||||
}
|
||||
|
||||
if (notification.duration !== 0) {
|
||||
|
|
@ -294,13 +295,13 @@ class ToastChannel implements NotificationChannel {
|
|||
return null;
|
||||
}
|
||||
|
||||
const toast = createEl('div', { cls: `toast toast--${notification.type}` });
|
||||
const toast = createEl('div', { cls: `sn-toast sn-toast--${notification.type}` });
|
||||
toast.setAttribute('role', 'alert');
|
||||
toast.setAttribute('data-notification-id', id);
|
||||
|
||||
// 아이콘
|
||||
if (notification.icon !== false) {
|
||||
const icon = createEl('div', { cls: 'toast__icon' });
|
||||
const icon = createEl('div', { cls: 'sn-toast__icon' });
|
||||
const iconSvg = this.getIcon(notification.type);
|
||||
if (iconSvg) {
|
||||
icon.appendChild(iconSvg);
|
||||
|
|
@ -309,33 +310,33 @@ class ToastChannel implements NotificationChannel {
|
|||
}
|
||||
|
||||
// 내용
|
||||
const content = createEl('div', { cls: 'toast__content' });
|
||||
const content = createEl('div', { cls: 'sn-toast__content' });
|
||||
|
||||
if (notification.title) {
|
||||
const title = createEl('div', { cls: 'toast__title', text: notification.title });
|
||||
const title = createEl('div', { cls: 'sn-toast__title', text: notification.title });
|
||||
content.appendChild(title);
|
||||
}
|
||||
|
||||
const message = createEl('div', { cls: 'toast__message', text: notification.message });
|
||||
const message = createEl('div', { cls: 'sn-toast__message', text: notification.message });
|
||||
content.appendChild(message);
|
||||
|
||||
toast.appendChild(content);
|
||||
|
||||
// 닫기 버튼
|
||||
if (notification.closable !== false) {
|
||||
const closeBtn = createEl('button', { cls: 'toast__close', text: '×' });
|
||||
closeBtn.setAttribute('aria-label', '닫기');
|
||||
const closeBtn = createEl('button', { cls: 'sn-toast__close', text: '×' });
|
||||
closeBtn.setAttribute('aria-label', 'Close notification');
|
||||
closeBtn.onclick = () => this.dismiss(id);
|
||||
toast.appendChild(closeBtn);
|
||||
}
|
||||
|
||||
// 액션 버튼
|
||||
if (notification.actions && notification.actions.length > 0) {
|
||||
const actions = createEl('div', { cls: 'toast__actions' });
|
||||
const actions = createEl('div', { cls: 'sn-toast__actions' });
|
||||
|
||||
notification.actions.forEach((action) => {
|
||||
const btn = createEl('button', {
|
||||
cls: `toast__action toast__action--${action.style || 'secondary'}`,
|
||||
cls: `sn-toast__action sn-toast__action--${action.style || 'secondary'}`,
|
||||
text: action.label,
|
||||
});
|
||||
btn.onclick = async () => {
|
||||
|
|
@ -352,8 +353,8 @@ class ToastChannel implements NotificationChannel {
|
|||
|
||||
// 진행률 바
|
||||
if (notification.progress !== undefined) {
|
||||
const progressBar = createEl('div', { cls: 'toast__progress' });
|
||||
const fill = createEl('div', { cls: 'toast__progress-fill' });
|
||||
const progressBar = createEl('div', { cls: 'sn-toast__progress' });
|
||||
const fill = createEl('div', { cls: 'sn-toast__progress-fill' });
|
||||
fill.setAttribute('style', `--sn-progress-width:${notification.progress}%`);
|
||||
progressBar.appendChild(fill);
|
||||
toast.appendChild(progressBar);
|
||||
|
|
@ -362,33 +363,15 @@ class ToastChannel implements NotificationChannel {
|
|||
return toast;
|
||||
}
|
||||
|
||||
private getIcon(type: NotificationType): SVGElement | null {
|
||||
const iconConfig: Record<NotificationType, { viewBox: string; path: string }> = {
|
||||
success: {
|
||||
viewBox: '0 0 24 24',
|
||||
path: 'M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z',
|
||||
},
|
||||
error: {
|
||||
viewBox: '0 0 24 24',
|
||||
path: 'M19 6.41L17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12z',
|
||||
},
|
||||
warning: {
|
||||
viewBox: '0 0 24 24',
|
||||
path: 'M1 21h22L12 2 1 21zm12-3h-2v-2h2v2zm0-4h-2v-4h2v4z',
|
||||
},
|
||||
info: {
|
||||
viewBox: '0 0 24 24',
|
||||
path: 'M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm1 15h-2v-6h2v6zm0-8h-2V7h2v2z',
|
||||
},
|
||||
};
|
||||
private getIcon(type: NotificationType): HTMLElement {
|
||||
const iconMap = {
|
||||
success: 'check',
|
||||
error: 'x',
|
||||
warning: 'alert-triangle',
|
||||
info: 'info',
|
||||
} as const;
|
||||
|
||||
const config = iconConfig[type] || iconConfig.info;
|
||||
const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
|
||||
svg.setAttribute('viewBox', config.viewBox);
|
||||
const path = document.createElementNS('http://www.w3.org/2000/svg', 'path');
|
||||
path.setAttribute('d', config.path);
|
||||
svg.appendChild(path);
|
||||
return svg;
|
||||
return createIconElement(iconMap[type]);
|
||||
}
|
||||
|
||||
dismiss(notificationId: string): void {
|
||||
|
|
@ -399,8 +382,8 @@ class ToastChannel implements NotificationChannel {
|
|||
const toast = this.notifications.get(notificationId);
|
||||
if (!toast) return;
|
||||
|
||||
toast.classList.remove('toast--show');
|
||||
toast.classList.add('toast--hide');
|
||||
toast.classList.remove('sn-toast--show');
|
||||
toast.classList.add('sn-toast--hide');
|
||||
|
||||
setTimeout(() => {
|
||||
toast.remove();
|
||||
|
|
@ -445,27 +428,27 @@ class ModalChannel implements NotificationChannel {
|
|||
}
|
||||
|
||||
private createModal(notification: NotificationOptions, id: string): HTMLElement {
|
||||
const overlay = createEl('div', { cls: 'modal-overlay' });
|
||||
const overlay = createEl('div', { cls: 'sn-modal-overlay' });
|
||||
overlay.setAttribute('data-notification-id', id);
|
||||
|
||||
const modal = createEl('div', { cls: `modal modal--${notification.type}` });
|
||||
const modal = createEl('div', { cls: `sn-modal sn-modal--${notification.type}` });
|
||||
modal.setAttribute('role', 'dialog');
|
||||
modal.setAttribute('aria-modal', 'true');
|
||||
modal.setAttribute('aria-labelledby', `modal-title-${id}`);
|
||||
|
||||
// 헤더
|
||||
const header = createEl('div', { cls: 'modal__header' });
|
||||
const header = createEl('div', { cls: 'sn-modal__header' });
|
||||
|
||||
const title = createEl('h2', {
|
||||
cls: 'modal__title',
|
||||
cls: 'sn-modal__title',
|
||||
text: notification.title || this.getDefaultTitle(notification.type),
|
||||
});
|
||||
title.id = `modal-title-${id}`;
|
||||
header.appendChild(title);
|
||||
|
||||
if (notification.closable !== false) {
|
||||
const closeBtn = createEl('button', { cls: 'modal__close', text: '×' });
|
||||
closeBtn.setAttribute('aria-label', '닫기');
|
||||
const closeBtn = createEl('button', { cls: 'sn-modal__close', text: '×' });
|
||||
closeBtn.setAttribute('aria-label', 'Close dialog');
|
||||
closeBtn.onclick = () => this.dismiss(id);
|
||||
header.appendChild(closeBtn);
|
||||
}
|
||||
|
|
@ -473,16 +456,16 @@ class ModalChannel implements NotificationChannel {
|
|||
modal.appendChild(header);
|
||||
|
||||
// 내용
|
||||
const content = createEl('div', { cls: 'modal__content', text: notification.message });
|
||||
const content = createEl('div', { cls: 'sn-modal__content', text: notification.message });
|
||||
modal.appendChild(content);
|
||||
|
||||
// 액션
|
||||
if (notification.actions && notification.actions.length > 0) {
|
||||
const footer = createEl('div', { cls: 'modal__footer' });
|
||||
const footer = createEl('div', { cls: 'sn-modal__footer' });
|
||||
|
||||
notification.actions.forEach((action) => {
|
||||
const btn = createEl('button', {
|
||||
cls: `modal__action modal__action--${action.style || 'secondary'}`,
|
||||
cls: `sn-modal__action sn-modal__action--${action.style || 'secondary'}`,
|
||||
text: action.label,
|
||||
});
|
||||
btn.onclick = async () => {
|
||||
|
|
@ -511,12 +494,12 @@ class ModalChannel implements NotificationChannel {
|
|||
|
||||
private getDefaultTitle(type: NotificationType): string {
|
||||
const titles = {
|
||||
success: '성공',
|
||||
error: '오류',
|
||||
warning: '경고',
|
||||
info: '알림',
|
||||
success: 'Success',
|
||||
error: 'Error',
|
||||
warning: 'Warning',
|
||||
info: 'Notice',
|
||||
};
|
||||
return titles[type] || '알림';
|
||||
return titles[type] || 'Notice';
|
||||
}
|
||||
|
||||
private trapFocus(modal: HTMLElement): void {
|
||||
|
|
@ -552,7 +535,7 @@ class ModalChannel implements NotificationChannel {
|
|||
const modal = this.activeModals.get(notificationId);
|
||||
if (!modal) return;
|
||||
|
||||
modal.classList.add('modal-overlay--hide');
|
||||
modal.classList.add('sn-modal-overlay--hide');
|
||||
|
||||
setTimeout(() => {
|
||||
modal.remove();
|
||||
|
|
@ -582,9 +565,9 @@ class StatusBarChannel implements NotificationChannel {
|
|||
}
|
||||
|
||||
private createStatusBar(): void {
|
||||
this.statusBar = document.querySelector('.status-bar');
|
||||
this.statusBar = document.querySelector('.sn-status-bar');
|
||||
if (!this.statusBar) {
|
||||
const statusBar = createEl('div', { cls: 'status-bar' });
|
||||
const statusBar = createEl('div', { cls: 'sn-status-bar' });
|
||||
document.body.appendChild(statusBar);
|
||||
this.statusBar = statusBar;
|
||||
}
|
||||
|
|
@ -597,9 +580,9 @@ class StatusBarChannel implements NotificationChannel {
|
|||
this.currentNotification = id;
|
||||
|
||||
if (this.statusBar) {
|
||||
this.statusBar.className = `status-bar status-bar--${notification.type}`;
|
||||
this.statusBar.className = `sn-status-bar sn-status-bar--${notification.type}`;
|
||||
this.statusBar.textContent = notification.message;
|
||||
this.statusBar.classList.add('status-bar--show');
|
||||
this.statusBar.classList.add('sn-status-bar--show');
|
||||
}
|
||||
|
||||
if (this.timeout) {
|
||||
|
|
@ -616,7 +599,7 @@ class StatusBarChannel implements NotificationChannel {
|
|||
dismiss(notificationId: string): void {
|
||||
if (this.currentNotification !== notificationId) return;
|
||||
|
||||
this.statusBar?.classList.remove('status-bar--show');
|
||||
this.statusBar?.classList.remove('sn-status-bar--show');
|
||||
this.currentNotification = null;
|
||||
|
||||
if (this.timeout) {
|
||||
|
|
@ -759,7 +742,7 @@ class ProgressNotification implements IProgressNotification {
|
|||
|
||||
complete(message?: string): void {
|
||||
this.options.type = 'success';
|
||||
this.options.message = message || '완료되었습니다';
|
||||
this.options.message = message || 'Completed';
|
||||
this.options.progress = 100;
|
||||
this.render();
|
||||
|
||||
|
|
@ -792,7 +775,7 @@ class ProgressNotification implements IProgressNotification {
|
|||
if (!element) return;
|
||||
|
||||
// 메시지 업데이트
|
||||
const messageEl = element.querySelector('.toast__message');
|
||||
const messageEl = element.querySelector('.sn-toast__message');
|
||||
if (messageEl) {
|
||||
let text = this.options.message || '';
|
||||
|
||||
|
|
@ -804,13 +787,13 @@ class ProgressNotification implements IProgressNotification {
|
|||
}
|
||||
|
||||
// 진행률 바 업데이트
|
||||
const progressFill = element.querySelector('.toast__progress-fill');
|
||||
const progressFill = element.querySelector('.sn-toast__progress-fill');
|
||||
if (progressFill instanceof HTMLElement) {
|
||||
progressFill.setAttribute('style', `--sn-progress-width:${this.options.progress}%`);
|
||||
}
|
||||
|
||||
// 타입별 스타일 업데이트
|
||||
element.className = `toast toast--${this.options.type} toast--show`;
|
||||
element.className = `sn-toast sn-toast--${this.options.type} sn-toast--show`;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -1031,12 +1014,12 @@ export class NotificationManager implements INotificationAPI {
|
|||
|
||||
void modal.send({
|
||||
type: 'info',
|
||||
title: options?.title || '확인',
|
||||
title: options?.title || 'Confirm',
|
||||
message,
|
||||
closable: false,
|
||||
actions: [
|
||||
{
|
||||
label: options?.cancelText || '취소',
|
||||
label: options?.cancelText || 'Cancel',
|
||||
style: 'secondary',
|
||||
callback: () => {
|
||||
modal.dismiss(id);
|
||||
|
|
@ -1044,7 +1027,7 @@ export class NotificationManager implements INotificationAPI {
|
|||
},
|
||||
},
|
||||
{
|
||||
label: options?.confirmText || '확인',
|
||||
label: options?.confirmText || 'Confirm',
|
||||
style: options?.confirmStyle || 'primary',
|
||||
callback: () => {
|
||||
modal.dismiss(id);
|
||||
|
|
@ -1076,11 +1059,11 @@ export class NotificationManager implements INotificationAPI {
|
|||
|
||||
void modal.send({
|
||||
type: 'info',
|
||||
title: title || '알림',
|
||||
title: title || 'Notice',
|
||||
message,
|
||||
actions: [
|
||||
{
|
||||
label: '확인',
|
||||
label: 'OK',
|
||||
style: 'primary',
|
||||
callback: () => {
|
||||
modal.dismiss(id);
|
||||
|
|
|
|||
|
|
@ -1,3 +1,5 @@
|
|||
import { createIconElement } from '../../utils/common/helpers';
|
||||
|
||||
/**
|
||||
* 로딩 인디케이터 컴포넌트 모음
|
||||
* - 스피너 애니메이션
|
||||
|
|
@ -35,24 +37,26 @@ export class SpinnerLoader {
|
|||
|
||||
create(): HTMLElement {
|
||||
this.element = createEl('div');
|
||||
this.element.className = `loading-spinner loading-spinner--${this.options.size}`;
|
||||
this.element.className = `sn-loading-spinner sn-loading-spinner--${this.options.size}`;
|
||||
this.element.setAttribute('role', 'status');
|
||||
this.element.setAttribute('aria-label', this.options.ariaLabel || '로딩 중');
|
||||
this.element.setAttribute('aria-label', this.options.ariaLabel || 'Loading');
|
||||
|
||||
this.element.appendChild(this.createSpinnerSvg());
|
||||
const spinnerGlyph = createEl('div');
|
||||
spinnerGlyph.className = 'sn-loading-spinner__glyph';
|
||||
this.element.appendChild(spinnerGlyph);
|
||||
|
||||
if (this.options.message) {
|
||||
const messageEl = createEl('span');
|
||||
messageEl.className = 'loading-message';
|
||||
messageEl.className = 'sn-loading-message';
|
||||
messageEl.textContent = this.options.message;
|
||||
this.element.appendChild(messageEl);
|
||||
}
|
||||
|
||||
// 스크린 리더용 라이브 영역
|
||||
const srOnly = createEl('span');
|
||||
srOnly.className = 'sr-only';
|
||||
srOnly.className = 'sn-sr-only';
|
||||
srOnly.setAttribute('aria-live', 'polite');
|
||||
srOnly.textContent = this.options.message || '데이터를 불러오는 중입니다';
|
||||
srOnly.textContent = this.options.message || 'Loading data';
|
||||
this.element.appendChild(srOnly);
|
||||
|
||||
return this.element;
|
||||
|
|
@ -61,8 +65,8 @@ export class SpinnerLoader {
|
|||
updateMessage(message: string) {
|
||||
if (!this.element) return;
|
||||
|
||||
const messageEl = this.element.querySelector('.loading-message');
|
||||
const srOnly = this.element.querySelector('.sr-only');
|
||||
const messageEl = this.element.querySelector('.sn-loading-message');
|
||||
const srOnly = this.element.querySelector('.sn-sr-only');
|
||||
|
||||
if (messageEl) {
|
||||
messageEl.textContent = message;
|
||||
|
|
@ -76,45 +80,6 @@ export class SpinnerLoader {
|
|||
this.element?.remove();
|
||||
this.element = null;
|
||||
}
|
||||
|
||||
private createSpinnerSvg(): SVGElement {
|
||||
const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
|
||||
svg.setAttribute('viewBox', '0 0 50 50');
|
||||
|
||||
const background = document.createElementNS('http://www.w3.org/2000/svg', 'circle');
|
||||
background.setAttribute('cx', '25');
|
||||
background.setAttribute('cy', '25');
|
||||
background.setAttribute('r', '20');
|
||||
background.setAttribute('fill', 'none');
|
||||
background.setAttribute('stroke', 'currentColor');
|
||||
background.setAttribute('stroke-width', '4');
|
||||
background.setAttribute('opacity', '0.3');
|
||||
svg.appendChild(background);
|
||||
|
||||
const arc = document.createElementNS('http://www.w3.org/2000/svg', 'circle');
|
||||
arc.setAttribute('cx', '25');
|
||||
arc.setAttribute('cy', '25');
|
||||
arc.setAttribute('r', '20');
|
||||
arc.setAttribute('fill', 'none');
|
||||
arc.setAttribute('stroke', 'currentColor');
|
||||
arc.setAttribute('stroke-width', '4');
|
||||
arc.setAttribute('stroke-dasharray', '90');
|
||||
arc.setAttribute('stroke-dashoffset', '60');
|
||||
arc.setAttribute('stroke-linecap', 'round');
|
||||
|
||||
const animate = document.createElementNS('http://www.w3.org/2000/svg', 'animateTransform');
|
||||
animate.setAttribute('attributeName', 'transform');
|
||||
animate.setAttribute('type', 'rotate');
|
||||
animate.setAttribute('from', '0 25 25');
|
||||
animate.setAttribute('to', '360 25 25');
|
||||
animate.setAttribute('dur', '1s');
|
||||
animate.setAttribute('repeatCount', 'indefinite');
|
||||
arc.appendChild(animate);
|
||||
|
||||
svg.appendChild(arc);
|
||||
|
||||
return svg;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -134,20 +99,20 @@ export class PulseLoader {
|
|||
|
||||
create(): HTMLElement {
|
||||
this.element = createEl('div');
|
||||
this.element.className = `loading-pulse loading-pulse--${this.options.size}`;
|
||||
this.element.className = `sn-loading-pulse sn-loading-pulse--${this.options.size}`;
|
||||
this.element.setAttribute('role', 'status');
|
||||
this.element.setAttribute('aria-label', this.options.ariaLabel || '로딩 중');
|
||||
this.element.setAttribute('aria-label', this.options.ariaLabel || 'Loading');
|
||||
|
||||
// 펄스 요소들
|
||||
for (let i = 0; i < 3; i++) {
|
||||
const pulse = createEl('div');
|
||||
pulse.className = `pulse-dot pulse-dot--${i + 1}`;
|
||||
pulse.className = `sn-pulse-dot sn-pulse-dot--${i + 1}`;
|
||||
this.element.appendChild(pulse);
|
||||
}
|
||||
|
||||
if (this.options.message) {
|
||||
const messageEl = createEl('span');
|
||||
messageEl.className = 'loading-message';
|
||||
messageEl.className = 'sn-loading-message';
|
||||
messageEl.textContent = this.options.message;
|
||||
this.element.appendChild(messageEl);
|
||||
}
|
||||
|
|
@ -192,9 +157,9 @@ export class SkeletonLoader {
|
|||
|
||||
create(): HTMLElement {
|
||||
this.element = createEl('div');
|
||||
this.element.className = 'loading-skeleton';
|
||||
this.element.className = 'sn-loading-skeleton';
|
||||
this.element.setAttribute('role', 'status');
|
||||
this.element.setAttribute('aria-label', '콘텐츠를 불러오는 중');
|
||||
this.element.setAttribute('aria-label', 'Loading content');
|
||||
|
||||
const lineCount = this.options.lines ?? 3;
|
||||
const heightModifier = this.getLineHeightModifier(this.options.lineHeight);
|
||||
|
|
@ -209,7 +174,7 @@ export class SkeletonLoader {
|
|||
|
||||
for (let i = 0; i < lineCount; i++) {
|
||||
const line = createEl('div');
|
||||
const classes = ['skeleton-line'];
|
||||
const classes = ['sn-skeleton-line'];
|
||||
if (this.options.animated !== false) {
|
||||
classes.push('skeleton-animated');
|
||||
}
|
||||
|
|
@ -236,16 +201,16 @@ export class SkeletonLoader {
|
|||
case '14px':
|
||||
case '0.75rem':
|
||||
case '0.875rem':
|
||||
return 'loading-skeleton--height-sm';
|
||||
return 'sn-loading-skeleton--height-sm';
|
||||
case 'lg':
|
||||
case 'large':
|
||||
case 'comfortable':
|
||||
case '24px':
|
||||
case '28px':
|
||||
case '1.5rem':
|
||||
return 'loading-skeleton--height-lg';
|
||||
return 'sn-loading-skeleton--height-lg';
|
||||
default:
|
||||
return 'loading-skeleton--height-md';
|
||||
return 'sn-loading-skeleton--height-md';
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -256,21 +221,21 @@ export class SkeletonLoader {
|
|||
case '0':
|
||||
case '0px':
|
||||
case '0rem':
|
||||
return 'loading-skeleton--spacing-none';
|
||||
return 'sn-loading-skeleton--spacing-none';
|
||||
case 'sm':
|
||||
case 'small':
|
||||
case 'compact':
|
||||
case '6px':
|
||||
case '0.375rem':
|
||||
return 'loading-skeleton--spacing-sm';
|
||||
return 'sn-loading-skeleton--spacing-sm';
|
||||
case 'lg':
|
||||
case 'large':
|
||||
case 'comfortable':
|
||||
case '16px':
|
||||
case '1rem':
|
||||
return 'loading-skeleton--spacing-lg';
|
||||
return 'sn-loading-skeleton--spacing-lg';
|
||||
default:
|
||||
return 'loading-skeleton--spacing-md';
|
||||
return 'sn-loading-skeleton--spacing-md';
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -297,16 +262,16 @@ export class DotsLoader {
|
|||
|
||||
create(): HTMLElement {
|
||||
this.element = createEl('div');
|
||||
this.element.className = `loading-dots loading-dots--${this.options.size}`;
|
||||
this.element.className = `sn-loading-dots sn-loading-dots--${this.options.size}`;
|
||||
this.element.setAttribute('role', 'status');
|
||||
this.element.setAttribute('aria-label', this.options.ariaLabel || '로딩 중');
|
||||
this.element.setAttribute('aria-label', this.options.ariaLabel || 'Loading');
|
||||
|
||||
const dotsContainer = createEl('div');
|
||||
dotsContainer.className = 'dots-container';
|
||||
dotsContainer.className = 'sn-dots-container';
|
||||
|
||||
for (let i = 0; i < 3; i++) {
|
||||
const dot = createEl('span');
|
||||
dot.className = 'dot';
|
||||
dot.className = 'sn-dot';
|
||||
dotsContainer.appendChild(dot);
|
||||
}
|
||||
|
||||
|
|
@ -314,7 +279,7 @@ export class DotsLoader {
|
|||
|
||||
if (this.options.message) {
|
||||
const messageEl = createEl('span');
|
||||
messageEl.className = 'loading-message';
|
||||
messageEl.className = 'sn-loading-message';
|
||||
messageEl.textContent = this.options.message;
|
||||
this.element.appendChild(messageEl);
|
||||
}
|
||||
|
|
@ -343,19 +308,19 @@ export class StatusIcon {
|
|||
|
||||
create(): HTMLElement {
|
||||
this.element = createEl('div');
|
||||
this.element.className = `status-icon status-icon--${this.type}`;
|
||||
this.element.className = `sn-status-icon sn-status-icon--${this.type}`;
|
||||
this.element.setAttribute('role', 'status');
|
||||
|
||||
const icon = this.getIcon();
|
||||
const iconEl = createEl('div');
|
||||
iconEl.className = 'status-icon__icon';
|
||||
iconEl.className = 'sn-status-icon__icon';
|
||||
iconEl.appendChild(icon);
|
||||
|
||||
this.element.appendChild(iconEl);
|
||||
|
||||
if (this.message) {
|
||||
const messageEl = createEl('span');
|
||||
messageEl.className = 'status-icon__message';
|
||||
messageEl.className = 'sn-status-icon__message';
|
||||
messageEl.textContent = this.message;
|
||||
this.element.appendChild(messageEl);
|
||||
}
|
||||
|
|
@ -367,84 +332,23 @@ export class StatusIcon {
|
|||
return this.element;
|
||||
}
|
||||
|
||||
private getIcon(): SVGElement {
|
||||
const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
|
||||
svg.setAttribute('viewBox', '0 0 24 24');
|
||||
svg.setAttribute('fill', 'none');
|
||||
private getIcon(): HTMLElement {
|
||||
const iconMap = {
|
||||
success: 'check',
|
||||
error: 'x',
|
||||
warning: 'alert-triangle',
|
||||
info: 'info',
|
||||
} as const;
|
||||
|
||||
if (this.type === 'warning') {
|
||||
const triangle = document.createElementNS('http://www.w3.org/2000/svg', 'path');
|
||||
triangle.setAttribute('d', 'M12 3L2 21H22L12 3Z');
|
||||
triangle.setAttribute('stroke', 'currentColor');
|
||||
triangle.setAttribute('stroke-width', '2');
|
||||
triangle.setAttribute('stroke-linejoin', 'round');
|
||||
svg.appendChild(triangle);
|
||||
|
||||
const line = document.createElementNS('http://www.w3.org/2000/svg', 'path');
|
||||
line.setAttribute('d', 'M12 9V13');
|
||||
line.setAttribute('stroke', 'currentColor');
|
||||
line.setAttribute('stroke-width', '2');
|
||||
line.setAttribute('stroke-linecap', 'round');
|
||||
svg.appendChild(line);
|
||||
|
||||
const dot = document.createElementNS('http://www.w3.org/2000/svg', 'circle');
|
||||
dot.setAttribute('cx', '12');
|
||||
dot.setAttribute('cy', '17');
|
||||
dot.setAttribute('r', '0.5');
|
||||
dot.setAttribute('fill', 'currentColor');
|
||||
svg.appendChild(dot);
|
||||
return svg;
|
||||
}
|
||||
|
||||
const circle = document.createElementNS('http://www.w3.org/2000/svg', 'circle');
|
||||
circle.setAttribute('cx', '12');
|
||||
circle.setAttribute('cy', '12');
|
||||
circle.setAttribute('r', '10');
|
||||
circle.setAttribute('stroke', 'currentColor');
|
||||
circle.setAttribute('stroke-width', '2');
|
||||
svg.appendChild(circle);
|
||||
|
||||
if (this.type === 'success') {
|
||||
const path = document.createElementNS('http://www.w3.org/2000/svg', 'path');
|
||||
path.setAttribute('d', 'M8 12L11 15L16 9');
|
||||
path.setAttribute('stroke', 'currentColor');
|
||||
path.setAttribute('stroke-width', '2');
|
||||
path.setAttribute('stroke-linecap', 'round');
|
||||
path.setAttribute('stroke-linejoin', 'round');
|
||||
svg.appendChild(path);
|
||||
} else if (this.type === 'error') {
|
||||
const path = document.createElementNS('http://www.w3.org/2000/svg', 'path');
|
||||
path.setAttribute('d', 'M15 9L9 15M9 9L15 15');
|
||||
path.setAttribute('stroke', 'currentColor');
|
||||
path.setAttribute('stroke-width', '2');
|
||||
path.setAttribute('stroke-linecap', 'round');
|
||||
path.setAttribute('stroke-linejoin', 'round');
|
||||
svg.appendChild(path);
|
||||
} else if (this.type === 'info') {
|
||||
const line = document.createElementNS('http://www.w3.org/2000/svg', 'path');
|
||||
line.setAttribute('d', 'M12 11V16');
|
||||
line.setAttribute('stroke', 'currentColor');
|
||||
line.setAttribute('stroke-width', '2');
|
||||
line.setAttribute('stroke-linecap', 'round');
|
||||
svg.appendChild(line);
|
||||
|
||||
const dot = document.createElementNS('http://www.w3.org/2000/svg', 'circle');
|
||||
dot.setAttribute('cx', '12');
|
||||
dot.setAttribute('cy', '8');
|
||||
dot.setAttribute('r', '0.5');
|
||||
dot.setAttribute('fill', 'currentColor');
|
||||
svg.appendChild(dot);
|
||||
}
|
||||
|
||||
return svg;
|
||||
return createIconElement(iconMap[this.type]);
|
||||
}
|
||||
|
||||
private getAriaLabel(): string {
|
||||
const labels = {
|
||||
success: '성공',
|
||||
error: '오류',
|
||||
warning: '경고',
|
||||
info: '정보',
|
||||
success: 'Success',
|
||||
error: 'Error',
|
||||
warning: 'Warning',
|
||||
info: 'Info',
|
||||
};
|
||||
|
||||
const baseLabel = labels[this.type];
|
||||
|
|
@ -454,12 +358,12 @@ export class StatusIcon {
|
|||
updateMessage(message: string) {
|
||||
if (!this.element) return;
|
||||
|
||||
const messageEl = this.element.querySelector('.status-icon__message');
|
||||
const messageEl = this.element.querySelector('.sn-status-icon__message');
|
||||
if (messageEl) {
|
||||
messageEl.textContent = message;
|
||||
} else {
|
||||
const newMessageEl = createEl('span');
|
||||
newMessageEl.className = 'status-icon__message';
|
||||
newMessageEl.className = 'sn-status-icon__message';
|
||||
newMessageEl.textContent = message;
|
||||
this.element.appendChild(newMessageEl);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -337,13 +337,13 @@ export class StatusMessageDisplay {
|
|||
* 컴포넌트 생성
|
||||
*/
|
||||
create(container: HTMLElement): HTMLElement {
|
||||
this.element = createEl('div', { cls: 'status-message-display' });
|
||||
this.element = createEl('div', { cls: 'sn-status-message-display' });
|
||||
this.element.setAttribute('role', 'log');
|
||||
this.element.setAttribute('aria-label', '상태 메시지 로그');
|
||||
this.element.setAttribute('aria-label', 'Status message log');
|
||||
this.element.setAttribute('aria-live', 'polite');
|
||||
|
||||
// 헤더
|
||||
const header = createEl('div', { cls: 'status-message-display__header' });
|
||||
const header = createEl('div', { cls: 'sn-status-message-display__header' });
|
||||
|
||||
const title = createEl('h3', {
|
||||
text: this.getLocalizedText('Status messages', '상태 메시지'),
|
||||
|
|
@ -351,11 +351,11 @@ export class StatusMessageDisplay {
|
|||
header.appendChild(title);
|
||||
|
||||
// 컨트롤
|
||||
const controls = createEl('div', { cls: 'status-message-display__controls' });
|
||||
const controls = createEl('div', { cls: 'sn-status-message-display__controls' });
|
||||
|
||||
// 언어 선택
|
||||
const langSelect = createEl('select', { cls: 'status-message-display__lang-select' });
|
||||
langSelect.setAttribute('aria-label', '언어 선택');
|
||||
const langSelect = createEl('select', { cls: 'sn-status-message-display__lang-select' });
|
||||
langSelect.setAttribute('aria-label', 'Language selection');
|
||||
|
||||
const languages = [
|
||||
{ value: 'ko', label: '한국어' },
|
||||
|
|
@ -388,7 +388,7 @@ export class StatusMessageDisplay {
|
|||
|
||||
// 클리어 버튼
|
||||
const clearBtn = createEl('button', {
|
||||
cls: 'status-message-display__clear',
|
||||
cls: 'sn-status-message-display__clear',
|
||||
text: this.getLocalizedText('Clear', '지우기'),
|
||||
});
|
||||
clearBtn.addEventListener('click', () => this.clear());
|
||||
|
|
@ -398,7 +398,7 @@ export class StatusMessageDisplay {
|
|||
this.element.appendChild(header);
|
||||
|
||||
// 메시지 컨테이너
|
||||
const messageContainer = createEl('div', { cls: 'status-message-display__messages' });
|
||||
const messageContainer = createEl('div', { cls: 'sn-status-message-display__messages' });
|
||||
this.element.appendChild(messageContainer);
|
||||
|
||||
container.appendChild(this.element);
|
||||
|
|
@ -433,17 +433,17 @@ export class StatusMessageDisplay {
|
|||
private renderMessage(message: StatusMessage) {
|
||||
if (!this.element) return;
|
||||
|
||||
const messageContainer = this.element.querySelector('.status-message-display__messages');
|
||||
const messageContainer = this.element.querySelector('.sn-status-message-display__messages');
|
||||
if (!messageContainer) return;
|
||||
|
||||
const messageEl = createEl('div', {
|
||||
cls: `status-message status-message--${message.type}`,
|
||||
cls: `sn-status-message sn-status-message--${message.type}`,
|
||||
});
|
||||
|
||||
// 타임스탬프
|
||||
if (this.showTimestamp && message.timestamp) {
|
||||
const timestamp = createEl('span', {
|
||||
cls: 'status-message__timestamp',
|
||||
cls: 'sn-status-message__timestamp',
|
||||
text: this.formatTimestamp(message.timestamp),
|
||||
});
|
||||
messageEl.appendChild(timestamp);
|
||||
|
|
@ -451,14 +451,14 @@ export class StatusMessageDisplay {
|
|||
|
||||
// 아이콘
|
||||
const icon = createEl('span', {
|
||||
cls: 'status-message__icon',
|
||||
cls: 'sn-status-message__icon',
|
||||
text: this.getMessageIcon(message.type),
|
||||
});
|
||||
messageEl.appendChild(icon);
|
||||
|
||||
// 메시지 텍스트
|
||||
const text = createEl('span', {
|
||||
cls: 'status-message__text',
|
||||
cls: 'sn-status-message__text',
|
||||
text: MessageStore.getMessage(message.key, this.currentLanguage, message.params),
|
||||
});
|
||||
messageEl.appendChild(text);
|
||||
|
|
@ -472,7 +472,7 @@ export class StatusMessageDisplay {
|
|||
|
||||
// 애니메이션
|
||||
requestAnimationFrame(() => {
|
||||
messageEl.classList.add('status-message--show');
|
||||
messageEl.classList.add('sn-status-message--show');
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -516,7 +516,7 @@ export class StatusMessageDisplay {
|
|||
private rerender() {
|
||||
if (!this.element) return;
|
||||
|
||||
const messageContainer = this.element.querySelector('.status-message-display__messages');
|
||||
const messageContainer = this.element.querySelector('.sn-status-message-display__messages');
|
||||
if (!messageContainer) return;
|
||||
|
||||
// 기존 메시지 제거
|
||||
|
|
@ -536,7 +536,7 @@ export class StatusMessageDisplay {
|
|||
|
||||
if (this.element) {
|
||||
const messageContainer = this.element.querySelector(
|
||||
'.status-message-display__messages'
|
||||
'.sn-status-message-display__messages'
|
||||
);
|
||||
if (messageContainer) {
|
||||
messageContainer.replaceChildren();
|
||||
|
|
@ -592,7 +592,7 @@ export class StatusMessageManager {
|
|||
const lang = navigator.language.toLowerCase();
|
||||
|
||||
if (lang.startsWith('ko')) {
|
||||
this.currentLanguage = 'ko';
|
||||
this.currentLanguage = 'en';
|
||||
} else if (lang.startsWith('ja')) {
|
||||
this.currentLanguage = 'ja';
|
||||
} else if (lang.startsWith('zh')) {
|
||||
|
|
|
|||
|
|
@ -1,3 +1,5 @@
|
|||
import { setIcon } from 'obsidian';
|
||||
|
||||
/**
|
||||
* 공통 헬퍼 유틸리티 함수들
|
||||
* 자주 사용되는 유틸리티 로직을 중앙화
|
||||
|
|
@ -65,7 +67,7 @@ export async function retry<T>(
|
|||
): Promise<T> {
|
||||
const { maxAttempts = 3, delay = 1000, backoff = true, onRetry } = options;
|
||||
|
||||
let lastError: Error;
|
||||
let lastError: Error | undefined;
|
||||
|
||||
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
|
||||
try {
|
||||
|
|
@ -85,7 +87,7 @@ export async function retry<T>(
|
|||
}
|
||||
}
|
||||
|
||||
throw lastError!;
|
||||
throw lastError ?? new Error('Operation failed after retry attempts');
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -93,7 +95,7 @@ export async function retry<T>(
|
|||
*/
|
||||
export function safeJsonParse<T = unknown>(jsonString: string, fallback: T): T {
|
||||
try {
|
||||
return JSON.parse(jsonString);
|
||||
return JSON.parse(jsonString) as T;
|
||||
} catch {
|
||||
return fallback;
|
||||
}
|
||||
|
|
@ -111,8 +113,9 @@ export function deepClone<T>(obj: T): T {
|
|||
return new Date(obj.getTime()) as unknown as T;
|
||||
}
|
||||
|
||||
if (obj instanceof Array) {
|
||||
return obj.map((item) => deepClone(item)) as unknown as T;
|
||||
if (Array.isArray(obj)) {
|
||||
const clonedArray = (obj as unknown[]).map((item) => deepClone(item));
|
||||
return clonedArray as unknown as T;
|
||||
}
|
||||
|
||||
if (obj instanceof Map) {
|
||||
|
|
@ -284,3 +287,22 @@ export function memoize<T extends (...args: unknown[]) => unknown>(
|
|||
return result;
|
||||
}) as T;
|
||||
}
|
||||
|
||||
/**
|
||||
* Obsidian 아이콘 요소 생성
|
||||
*/
|
||||
export function createIconElement(iconName: string, className?: string): HTMLSpanElement {
|
||||
const iconEl = document.createElement('span');
|
||||
if (className) {
|
||||
iconEl.className = className;
|
||||
}
|
||||
|
||||
if (typeof setIcon === 'function') {
|
||||
setIcon(iconEl, iconName);
|
||||
} else {
|
||||
iconEl.dataset.icon = iconName;
|
||||
iconEl.setAttribute('aria-hidden', 'true');
|
||||
}
|
||||
|
||||
return iconEl;
|
||||
}
|
||||
|
|
|
|||
2985
styles.css
Normal file
2985
styles.css
Normal file
File diff suppressed because it is too large
Load diff
Loading…
Reference in a new issue