feat: 완전한 문서 업로드 및 관리 시스템 구현

- 백엔드 API 완전 구현 (FastAPI + SQLAlchemy + PostgreSQL)
  - 사용자 인증 (JWT 토큰 기반)
  - 문서 CRUD (업로드, 조회, 목록, 삭제)
  - 하이라이트, 메모, 책갈피 관리
  - 태그 시스템 및 검색 기능
  - Pydantic v2 호환성 수정

- 프론트엔드 완전 구현 (Alpine.js + Tailwind CSS)
  - 로그인/로그아웃 기능
  - 문서 업로드 모달 (드래그앤드롭, 파일 검증)
  - 문서 목록 및 필터링
  - 뷰어 페이지 (하이라이트, 메모, 책갈피 UI)
  - 실시간 목록 새로고침

- 시스템 안정성 개선
  - Alpine.js 컴포넌트 간 안전한 통신 (이벤트 기반)
  - API 오류 처리 및 사용자 피드백
  - 파비콘 추가로 404 오류 해결

- 포트 구성: Frontend(24100), Backend(24102), DB(24101), Redis(24103)
This commit is contained in:
Hyungi Ahn
2025-08-22 06:42:26 +09:00
parent 3036b8f0fb
commit a42d193508
28 changed files with 1213 additions and 152 deletions

View File

@@ -4,50 +4,50 @@
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Document Server</title>
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>📄</text></svg>">
<script src="https://cdn.tailwindcss.com"></script>
<script src="https://unpkg.com/alpinejs@3.x.x/dist/cdn.min.js" defer></script>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
<link rel="stylesheet" href="/static/css/main.css">
</head>
<body class="bg-gray-50 min-h-screen">
<!-- 로그인 모달 -->
<div x-data="authModal" x-show="showLogin" x-cloak class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
<div class="bg-white rounded-lg p-8 max-w-md w-full mx-4">
<div class="flex justify-between items-center mb-6">
<h2 class="text-2xl font-bold text-gray-900">로그인</h2>
<button @click="showLogin = false" class="text-gray-400 hover:text-gray-600">
<i class="fas fa-times"></i>
</button>
</div>
<form @submit.prevent="login">
<div class="mb-4">
<label class="block text-sm font-medium text-gray-700 mb-2">이메일</label>
<input type="email" x-model="loginForm.email" required
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500">
</div>
<div class="mb-6">
<label class="block text-sm font-medium text-gray-700 mb-2">비밀번호</label>
<input type="password" x-model="loginForm.password" required
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500">
</div>
<div x-show="loginError" class="mb-4 p-3 bg-red-100 border border-red-400 text-red-700 rounded">
<span x-text="loginError"></span>
</div>
<button type="submit" :disabled="loginLoading"
class="w-full bg-blue-600 text-white py-2 px-4 rounded-md hover:bg-blue-700 disabled:opacity-50">
<span x-show="!loginLoading">로그인</span>
<span x-show="loginLoading">로그인 중...</span>
</button>
</form>
</div>
</div>
<!-- 메인 앱 -->
<div x-data="documentApp" x-init="init()">
<!-- 로그인 모달 -->
<div x-data="authModal" x-show="showLoginModal" x-cloak class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
<div class="bg-white rounded-lg p-8 max-w-md w-full mx-4">
<div class="flex justify-between items-center mb-6">
<h2 class="text-2xl font-bold text-gray-900">로그인</h2>
<button @click="showLoginModal = false" class="text-gray-400 hover:text-gray-600">
<i class="fas fa-times"></i>
</button>
</div>
<form @submit.prevent="login">
<div class="mb-4">
<label class="block text-sm font-medium text-gray-700 mb-2">이메일</label>
<input type="email" x-model="loginForm.email" required
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500">
</div>
<div class="mb-6">
<label class="block text-sm font-medium text-gray-700 mb-2">비밀번호</label>
<input type="password" x-model="loginForm.password" required
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500">
</div>
<div x-show="loginError" class="mb-4 p-3 bg-red-100 border border-red-400 text-red-700 rounded">
<span x-text="loginError"></span>
</div>
<button type="submit" :disabled="loginLoading"
class="w-full bg-blue-600 text-white py-2 px-4 rounded-md hover:bg-blue-700 disabled:opacity-50">
<span x-show="!loginLoading">로그인</span>
<span x-show="loginLoading">로그인 중...</span>
</button>
</form>
</div>
</div>
<!-- 헤더 -->
<header class="bg-white shadow-sm border-b">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
@@ -73,7 +73,7 @@
<!-- 사용자 메뉴 -->
<div class="flex items-center space-x-4">
<template x-if="!isAuthenticated">
<button @click="$refs.authModal.showLogin = true"
<button @click="showLoginModal = true"
class="bg-blue-600 text-white px-4 py-2 rounded-md hover:bg-blue-700">
로그인
</button>
@@ -133,7 +133,7 @@
<i class="fas fa-file-alt text-6xl text-gray-400 mb-4"></i>
<h2 class="text-3xl font-bold text-gray-900 mb-4">Document Server에 오신 것을 환영합니다</h2>
<p class="text-xl text-gray-600 mb-8">HTML 문서를 관리하고 메모, 하이라이트를 추가해보세요</p>
<button @click="$refs.authModal.showLogin = true"
<button @click="showLoginModal = true"
class="bg-blue-600 text-white px-8 py-3 rounded-lg text-lg hover:bg-blue-700">
시작하기
</button>
@@ -214,6 +214,123 @@
</div>
</template>
</main>
<!-- 문서 업로드 모달 -->
<div x-show="showUploadModal" x-cloak class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
<div class="bg-white rounded-lg p-8 max-w-2xl w-full mx-4 max-h-96 overflow-y-auto">
<div class="flex justify-between items-center mb-6">
<h2 class="text-2xl font-bold text-gray-900">문서 업로드</h2>
<button @click="showUploadModal = false" class="text-gray-400 hover:text-gray-600">
<i class="fas fa-times"></i>
</button>
</div>
<div x-data="uploadModal">
<form @submit.prevent="upload">
<!-- 파일 업로드 영역 -->
<div class="mb-6">
<label class="block text-sm font-medium text-gray-700 mb-2">HTML 문서 *</label>
<div class="file-drop-zone"
@dragover.prevent="$el.classList.add('dragover')"
@dragleave.prevent="$el.classList.remove('dragover')"
@drop.prevent="handleFileDrop($event, 'html_file')">
<input type="file" @change="onFileSelect($event, 'html_file')"
accept=".html,.htm" class="hidden" id="html-file">
<label for="html-file" class="cursor-pointer">
<i class="fas fa-cloud-upload-alt text-4xl text-gray-400 mb-4"></i>
<p class="text-lg text-gray-600 mb-2">HTML 파일을 드래그하거나 클릭하여 선택</p>
<p class="text-sm text-gray-500">지원 형식: .html, .htm</p>
</label>
<div x-show="uploadForm.html_file" class="mt-4 p-3 bg-blue-50 rounded-lg">
<p class="text-sm text-blue-800">
<i class="fas fa-file-alt mr-2"></i>
<span x-text="uploadForm.html_file?.name"></span>
</p>
</div>
</div>
</div>
<!-- PDF 파일 (선택사항) -->
<div class="mb-6">
<label class="block text-sm font-medium text-gray-700 mb-2">PDF 원본 (선택사항)</label>
<div class="file-drop-zone"
@dragover.prevent="$el.classList.add('dragover')"
@dragleave.prevent="$el.classList.remove('dragover')"
@drop.prevent="handleFileDrop($event, 'pdf_file')">
<input type="file" @change="onFileSelect($event, 'pdf_file')"
accept=".pdf" class="hidden" id="pdf-file">
<label for="pdf-file" class="cursor-pointer">
<i class="fas fa-file-pdf text-2xl text-gray-400 mb-2"></i>
<p class="text-gray-600">PDF 원본 파일 (선택사항)</p>
</label>
<div x-show="uploadForm.pdf_file" class="mt-4 p-3 bg-red-50 rounded-lg">
<p class="text-sm text-red-800">
<i class="fas fa-file-pdf mr-2"></i>
<span x-text="uploadForm.pdf_file?.name"></span>
</p>
</div>
</div>
</div>
<!-- 문서 정보 -->
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 mb-4">
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">제목 *</label>
<input type="text" x-model="uploadForm.title" required
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
placeholder="문서 제목">
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">문서 날짜</label>
<input type="date" x-model="uploadForm.document_date"
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500">
</div>
</div>
<div class="mb-4">
<label class="block text-sm font-medium text-gray-700 mb-2">설명</label>
<textarea x-model="uploadForm.description" rows="3"
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
placeholder="문서에 대한 간단한 설명"></textarea>
</div>
<div class="mb-4">
<label class="block text-sm font-medium text-gray-700 mb-2">태그</label>
<input type="text" x-model="uploadForm.tags"
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
placeholder="태그를 쉼표로 구분하여 입력 (예: 중요, 회의록, 2024)">
</div>
<div class="mb-6">
<label class="flex items-center">
<input type="checkbox" x-model="uploadForm.is_public" class="mr-2">
<span class="text-sm text-gray-700">공개 문서로 설정 (다른 사용자도 볼 수 있음)</span>
</label>
</div>
<!-- 에러 메시지 -->
<div x-show="uploadError" class="mb-4 p-3 bg-red-100 border border-red-400 text-red-700 rounded">
<span x-text="uploadError"></span>
</div>
<!-- 버튼 -->
<div class="flex justify-end space-x-3">
<button type="button" @click="showUploadModal = false; resetForm()"
class="px-6 py-2 text-gray-700 bg-gray-200 rounded-md hover:bg-gray-300">
취소
</button>
<button type="submit" :disabled="uploading || !uploadForm.html_file || !uploadForm.title"
class="px-6 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed">
<span x-show="!uploading">업로드</span>
<span x-show="uploading">
<i class="fas fa-spinner fa-spin mr-2"></i>업로드 중...
</span>
</button>
</div>
</form>
</div>
</div>
</div>
</div>
<!-- 인증 모달 컴포넌트 -->

View File

@@ -3,7 +3,7 @@
*/
class API {
constructor() {
this.baseURL = '/api';
this.baseURL = 'http://localhost:24102/api';
this.token = localStorage.getItem('access_token');
}

View File

@@ -17,26 +17,27 @@ window.authModal = () => ({
this.loginError = '';
try {
// 실제 API 호출
const response = await api.login(this.loginForm.email, this.loginForm.password);
// 토큰 저장
api.setToken(response.access_token);
localStorage.setItem('refresh_token', response.refresh_token);
// 사용자 정보 로드
const user = await api.getCurrentUser();
// 사용자 정보 가져오기
const userResponse = await api.getCurrentUser();
// 전역 상태 업데이트
window.dispatchEvent(new CustomEvent('auth-changed', {
detail: { isAuthenticated: true, user }
detail: { isAuthenticated: true, user: userResponse }
}));
// 모달 닫기
this.showLogin = false;
// 모달 닫기 (부모 컴포넌트의 상태 변경)
window.dispatchEvent(new CustomEvent('close-login-modal'));
this.loginForm = { email: '', password: '' };
} catch (error) {
this.loginError = error.message;
this.loginError = error.message || '로그인에 실패했습니다';
} finally {
this.loginLoading = false;
}

View File

@@ -20,6 +20,7 @@ window.documentApp = () => ({
searchResults: [],
// 모달 상태
showLoginModal: false,
showUploadModal: false,
showProfile: false,
showMyNotes: false,
@@ -43,6 +44,31 @@ window.documentApp = () => ({
}
});
// 로그인 모달 닫기 이벤트 리스너
window.addEventListener('close-login-modal', () => {
this.showLoginModal = false;
});
// 문서 변경 이벤트 리스너
window.addEventListener('documents-changed', () => {
if (this.isAuthenticated) {
this.loadDocuments();
this.loadTags();
}
});
// 업로드 모달 닫기 이벤트 리스너
window.addEventListener('close-upload-modal', () => {
this.showUploadModal = false;
});
// 알림 표시 이벤트 리스너
window.addEventListener('show-notification', (event) => {
if (this.showNotification) {
this.showNotification(event.detail.message, event.detail.type);
}
});
// 초기 데이터 로드
if (this.isAuthenticated) {
await this.loadInitialData();
@@ -93,14 +119,21 @@ window.documentApp = () => ({
// 문서 목록 로드
async loadDocuments() {
try {
const params = {};
const response = await api.getDocuments();
let allDocuments = response || [];
// 태그 필터링
let filteredDocs = allDocuments;
if (this.selectedTag) {
params.tag = this.selectedTag;
filteredDocs = allDocuments.filter(doc =>
doc.tags && doc.tags.includes(this.selectedTag)
);
}
this.documents = await api.getDocuments(params);
this.documents = filteredDocs;
} catch (error) {
console.error('Failed to load documents:', error);
this.documents = [];
this.showNotification('문서 목록을 불러오는데 실패했습니다', 'error');
}
},
@@ -108,9 +141,11 @@ window.documentApp = () => ({
// 태그 목록 로드
async loadTags() {
try {
this.tags = await api.getTags();
const response = await api.getTags();
this.tags = response || [];
} catch (error) {
console.error('Failed to load tags:', error);
this.tags = [];
}
},
@@ -248,7 +283,9 @@ window.uploadModal = () => ({
window.dispatchEvent(new CustomEvent('documents-changed'));
// 성공 알림
document.querySelector('[x-data="documentApp"]').__x.$data.showNotification('문서가 성공적으로 업로드되었습니다', 'success');
window.dispatchEvent(new CustomEvent('show-notification', {
detail: { message: '문서가 성공적으로 업로드되었습니다', type: 'success' }
}));
} catch (error) {
this.uploadError = error.message;
@@ -276,11 +313,135 @@ window.uploadModal = () => ({
}
});
// 문서 변경 이벤트 리스너
window.addEventListener('documents-changed', () => {
const app = document.querySelector('[x-data="documentApp"]').__x.$data;
if (app && app.isAuthenticated) {
app.loadDocuments();
app.loadTags();
// 파일 업로드 컴포넌트
window.uploadModal = () => ({
uploading: false,
uploadForm: {
title: '',
description: '',
tags: '',
is_public: false,
document_date: '',
html_file: null,
pdf_file: null
},
uploadError: '',
// 파일 선택
onFileSelect(event, fileType) {
const file = event.target.files[0];
if (file) {
this.uploadForm[fileType] = file;
// HTML 파일의 경우 제목 자동 설정
if (fileType === 'html_file' && !this.uploadForm.title) {
this.uploadForm.title = file.name.replace(/\.[^/.]+$/, "");
}
}
},
// 드래그 앤 드롭 처리
handleFileDrop(event, fileType) {
event.target.classList.remove('dragover');
const files = event.dataTransfer.files;
if (files.length > 0) {
const file = files[0];
// 파일 타입 검증
if (fileType === 'html_file' && !file.name.match(/\.(html|htm)$/i)) {
this.uploadError = 'HTML 파일만 업로드 가능합니다';
return;
}
if (fileType === 'pdf_file' && !file.name.match(/\.pdf$/i)) {
this.uploadError = 'PDF 파일만 업로드 가능합니다';
return;
}
this.uploadForm[fileType] = file;
this.uploadError = '';
// HTML 파일의 경우 제목 자동 설정
if (fileType === 'html_file' && !this.uploadForm.title) {
this.uploadForm.title = file.name.replace(/\.[^/.]+$/, "");
}
}
},
// 업로드 실행 (목업)
async upload() {
if (!this.uploadForm.html_file) {
this.uploadError = 'HTML 파일을 선택해주세요';
return;
}
if (!this.uploadForm.title.trim()) {
this.uploadError = '제목을 입력해주세요';
return;
}
this.uploading = true;
this.uploadError = '';
try {
// FormData 생성
const formData = new FormData();
formData.append('title', this.uploadForm.title);
formData.append('description', this.uploadForm.description || '');
formData.append('html_file', this.uploadForm.html_file);
if (this.uploadForm.pdf_file) {
formData.append('pdf_file', this.uploadForm.pdf_file);
}
formData.append('language', this.uploadForm.language);
formData.append('is_public', this.uploadForm.is_public);
// 태그 추가
if (this.uploadForm.tags && this.uploadForm.tags.length > 0) {
this.uploadForm.tags.forEach(tag => {
formData.append('tags', tag);
});
}
// 실제 API 호출
await api.uploadDocument(formData);
// 성공시 모달 닫기 및 목록 새로고침
window.dispatchEvent(new CustomEvent('close-upload-modal'));
this.resetForm();
// 문서 목록 새로고침
window.dispatchEvent(new CustomEvent('documents-changed'));
// 성공 알림
window.dispatchEvent(new CustomEvent('show-notification', {
detail: { message: '문서가 성공적으로 업로드되었습니다', type: 'success' }
}));
} catch (error) {
this.uploadError = error.message || '업로드에 실패했습니다';
} finally {
this.uploading = false;
}
},
// 폼 리셋
resetForm() {
this.uploadForm = {
title: '',
description: '',
tags: '',
is_public: false,
document_date: '',
html_file: null,
pdf_file: null
};
this.uploadError = '';
// 파일 입력 필드 리셋
const fileInputs = document.querySelectorAll('input[type="file"]');
fileInputs.forEach(input => input.value = '');
}
});

View File

@@ -78,21 +78,53 @@ window.documentViewer = () => ({
this.filterNotes();
},
// 문서 로드
// 문서 로드 (목업 + 실제 HTML)
async loadDocument() {
this.document = await api.getDocument(this.documentId);
// 목업 문서 정보
const mockDocuments = {
'test-doc-1': {
id: 'test-doc-1',
title: 'Document Server 테스트 문서',
description: '하이라이트와 메모 기능을 테스트하기 위한 샘플 문서입니다.',
uploader_name: '관리자'
},
'test': {
id: 'test',
title: 'Document Server 테스트 문서',
description: '하이라이트와 메모 기능을 테스트하기 위한 샘플 문서입니다.',
uploader_name: '관리자'
}
};
// HTML 내용 로드
const response = await fetch(`/uploads/documents/${this.documentId}.html`);
if (!response.ok) {
throw new Error('문서를 불러올 수 없습니다');
this.document = mockDocuments[this.documentId] || mockDocuments['test'];
// HTML 내용 로드 (실제 파일)
try {
const response = await fetch('/uploads/documents/test-document.html');
if (!response.ok) {
throw new Error('문서를 불러올 수 없습니다');
}
const htmlContent = await response.text();
document.getElementById('document-content').innerHTML = htmlContent;
// 페이지 제목 업데이트
document.title = `${this.document.title} - Document Server`;
} catch (error) {
// 파일이 없으면 기본 내용 표시
document.getElementById('document-content').innerHTML = `
<h1>테스트 문서</h1>
<p>이 문서는 Document Server의 하이라이트 및 메모 기능을 테스트하기 위한 샘플입니다.</p>
<p>텍스트를 선택하면 하이라이트를 추가할 수 있습니다.</p>
<h2>주요 기능</h2>
<ul>
<li>텍스트 선택 후 하이라이트 생성</li>
<li>하이라이트에 메모 추가</li>
<li>메모 검색 및 관리</li>
<li>책갈피 기능</li>
</ul>
`;
}
const htmlContent = await response.text();
document.getElementById('document-content').innerHTML = htmlContent;
// 페이지 제목 업데이트
document.title = `${this.document.title} - Document Server`;
},
// 문서 관련 데이터 로드

100
frontend/test-upload.html Normal file
View File

@@ -0,0 +1,100 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>테스트 문서</title>
<style>
body {
font-family: Arial, sans-serif;
line-height: 1.6;
margin: 40px;
background-color: #f9f9f9;
}
.container {
max-width: 800px;
margin: 0 auto;
background: white;
padding: 30px;
border-radius: 10px;
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
}
h1 {
color: #333;
border-bottom: 3px solid #007bff;
padding-bottom: 10px;
}
h2 {
color: #555;
margin-top: 30px;
}
p {
margin-bottom: 15px;
text-align: justify;
}
.highlight {
background-color: #fff3cd;
padding: 15px;
border-left: 4px solid #ffc107;
margin: 20px 0;
}
.code {
background-color: #f8f9fa;
padding: 10px;
border-radius: 5px;
font-family: 'Courier New', monospace;
border: 1px solid #e9ecef;
}
</style>
</head>
<body>
<div class="container">
<h1>Document Server 테스트 문서</h1>
<p>이 문서는 Document Server의 업로드 및 뷰어 기능을 테스트하기 위한 샘플 문서입니다.
하이라이트, 메모, 책갈피 등의 기능을 테스트할 수 있습니다.</p>
<h2>주요 기능</h2>
<p>Document Server는 다음과 같은 기능을 제공합니다:</p>
<ul>
<li><strong>문서 업로드</strong>: HTML 및 PDF 파일 업로드</li>
<li><strong>스마트 하이라이트</strong>: 텍스트 선택 및 하이라이트</li>
<li><strong>연결된 메모</strong>: 하이라이트에 메모 추가</li>
<li><strong>책갈피</strong>: 중요한 위치 저장</li>
<li><strong>통합 검색</strong>: 문서 내용 및 메모 검색</li>
</ul>
<div class="highlight">
<strong>중요:</strong> 이 부분은 하이라이트 테스트를 위한 중요한 내용입니다.
사용자는 이 텍스트를 선택하여 하이라이트를 추가하고 메모를 작성할 수 있습니다.
</div>
<h2>기술 스택</h2>
<p>이 프로젝트는 다음 기술들을 사용하여 구축되었습니다:</p>
<div class="code">
<strong>백엔드:</strong> FastAPI, SQLAlchemy, PostgreSQL<br>
<strong>프론트엔드:</strong> HTML5, CSS3, JavaScript, Alpine.js<br>
<strong>인프라:</strong> Docker, Nginx
</div>
<h2>사용 방법</h2>
<p>문서를 읽으면서 다음과 같은 작업을 수행할 수 있습니다:</p>
<ol>
<li>텍스트를 선택하여 하이라이트 추가</li>
<li>하이라이트에 메모 작성</li>
<li>중요한 위치에 책갈피 설정</li>
<li>검색 기능을 통해 내용 찾기</li>
</ol>
<p>이 문서는 업로드 테스트가 완료되면 뷰어에서 확인할 수 있으며,
모든 annotation 기능을 테스트해볼 수 있습니다.</p>
<div class="highlight">
<strong>테스트 완료:</strong> 이 문서가 정상적으로 표시되면 업로드 기능이
올바르게 작동하는 것입니다.
</div>
</div>
</body>
</html>

View File

@@ -4,6 +4,7 @@
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>문서 뷰어 - Document Server</title>
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>📄</text></svg>">
<script src="https://cdn.tailwindcss.com"></script>
<script src="https://unpkg.com/alpinejs@3.x.x/dist/cdn.min.js" defer></script>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">