🚀 배포용: PDF 뷰어 개선 및 서적별 UI 데본씽크 스타일 적용
✨ 주요 개선사항: - PDF API 500 에러 수정 (한글 파일명 UTF-8 인코딩 처리) - PDF 뷰어 기능 완전 구현 (PDF.js 통합, 네비게이션, 확대/축소) - 서적별 문서 그룹화 UI 데본씽크 스타일로 개선 - PDF Manager 페이지 서적별 보기 기능 추가 - Alpine.js 로드 순서 최적화로 JavaScript 에러 해결 🎨 UI/UX 개선: - 확장/축소 가능한 아코디언 스타일 서적 목록 - 간결하고 직관적인 데본씽크 스타일 인터페이스 - PDF 상태 표시 (HTML 연결, 서적 분류) - 반응형 디자인 및 부드러운 애니메이션 🔧 기술적 개선: - PDF.js 워커 설정 및 토큰 인증 처리 - 서적별 PDF 자동 그룹화 로직 - Alpine.js 컴포넌트 초기화 최적화
This commit is contained in:
153
backend/database/migrations/005_create_memo_tree_tables.sql
Normal file
153
backend/database/migrations/005_create_memo_tree_tables.sql
Normal file
@@ -0,0 +1,153 @@
|
||||
-- 트리 구조 메모장 테이블 생성
|
||||
-- 005_create_memo_tree_tables.sql
|
||||
|
||||
-- 메모 트리 (프로젝트/워크스페이스)
|
||||
CREATE TABLE memo_trees (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
title VARCHAR(255) NOT NULL,
|
||||
description TEXT,
|
||||
tree_type VARCHAR(50) DEFAULT 'general', -- 'novel', 'research', 'project', 'general'
|
||||
template_data JSONB, -- 템플릿별 메타데이터
|
||||
settings JSONB DEFAULT '{}', -- 트리별 설정
|
||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
|
||||
is_public BOOLEAN DEFAULT FALSE,
|
||||
is_archived BOOLEAN DEFAULT FALSE
|
||||
);
|
||||
|
||||
-- 메모 노드 (트리의 각 노드)
|
||||
CREATE TABLE memo_nodes (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
tree_id UUID NOT NULL REFERENCES memo_trees(id) ON DELETE CASCADE,
|
||||
parent_id UUID REFERENCES memo_nodes(id) ON DELETE CASCADE,
|
||||
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
|
||||
-- 기본 정보
|
||||
title VARCHAR(500) NOT NULL,
|
||||
content TEXT, -- 실제 메모 내용 (Markdown)
|
||||
node_type VARCHAR(50) DEFAULT 'memo', -- 'folder', 'memo', 'chapter', 'character', 'plot'
|
||||
|
||||
-- 트리 구조 관리
|
||||
sort_order INTEGER DEFAULT 0,
|
||||
depth_level INTEGER DEFAULT 0,
|
||||
path TEXT, -- 경로 저장 (예: /1/3/7)
|
||||
|
||||
-- 메타데이터
|
||||
tags TEXT[], -- 태그 배열
|
||||
node_metadata JSONB DEFAULT '{}', -- 노드별 메타데이터 (캐릭터 정보, 플롯 정보 등)
|
||||
|
||||
-- 상태 관리
|
||||
status VARCHAR(50) DEFAULT 'draft', -- 'draft', 'writing', 'review', 'complete'
|
||||
word_count INTEGER DEFAULT 0,
|
||||
|
||||
-- 시간 정보
|
||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
-- 제약 조건
|
||||
CONSTRAINT no_self_reference CHECK (id != parent_id)
|
||||
);
|
||||
|
||||
-- 메모 노드 버전 관리 (선택적)
|
||||
CREATE TABLE memo_node_versions (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
node_id UUID NOT NULL REFERENCES memo_nodes(id) ON DELETE CASCADE,
|
||||
version_number INTEGER NOT NULL,
|
||||
title VARCHAR(500) NOT NULL,
|
||||
content TEXT,
|
||||
node_metadata JSONB DEFAULT '{}',
|
||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
|
||||
created_by UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
|
||||
UNIQUE(node_id, version_number)
|
||||
);
|
||||
|
||||
-- 메모 트리 공유 (협업 기능)
|
||||
CREATE TABLE memo_tree_shares (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
tree_id UUID NOT NULL REFERENCES memo_trees(id) ON DELETE CASCADE,
|
||||
shared_with_user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
permission_level VARCHAR(20) DEFAULT 'read', -- 'read', 'write', 'admin'
|
||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
|
||||
created_by UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
|
||||
UNIQUE(tree_id, shared_with_user_id)
|
||||
);
|
||||
|
||||
-- 인덱스 생성
|
||||
CREATE INDEX idx_memo_trees_user_id ON memo_trees(user_id);
|
||||
CREATE INDEX idx_memo_trees_type ON memo_trees(tree_type);
|
||||
CREATE INDEX idx_memo_nodes_tree_id ON memo_nodes(tree_id);
|
||||
CREATE INDEX idx_memo_nodes_parent_id ON memo_nodes(parent_id);
|
||||
CREATE INDEX idx_memo_nodes_user_id ON memo_nodes(user_id);
|
||||
CREATE INDEX idx_memo_nodes_path ON memo_nodes USING GIN(string_to_array(path, '/'));
|
||||
CREATE INDEX idx_memo_nodes_tags ON memo_nodes USING GIN(tags);
|
||||
CREATE INDEX idx_memo_nodes_type ON memo_nodes(node_type);
|
||||
CREATE INDEX idx_memo_node_versions_node_id ON memo_node_versions(node_id);
|
||||
CREATE INDEX idx_memo_tree_shares_tree_id ON memo_tree_shares(tree_id);
|
||||
|
||||
-- 트리거 함수: updated_at 자동 업데이트
|
||||
CREATE OR REPLACE FUNCTION update_memo_updated_at()
|
||||
RETURNS TRIGGER AS $$
|
||||
BEGIN
|
||||
NEW.updated_at = CURRENT_TIMESTAMP;
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
-- 트리거 생성
|
||||
CREATE TRIGGER memo_trees_updated_at
|
||||
BEFORE UPDATE ON memo_trees
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION update_memo_updated_at();
|
||||
|
||||
CREATE TRIGGER memo_nodes_updated_at
|
||||
BEFORE UPDATE ON memo_nodes
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION update_memo_updated_at();
|
||||
|
||||
-- 트리거 함수: 경로 자동 업데이트
|
||||
CREATE OR REPLACE FUNCTION update_memo_node_path()
|
||||
RETURNS TRIGGER AS $$
|
||||
BEGIN
|
||||
-- 루트 노드인 경우
|
||||
IF NEW.parent_id IS NULL THEN
|
||||
NEW.path = '/' || NEW.id::text;
|
||||
NEW.depth_level = 0;
|
||||
ELSE
|
||||
-- 부모 노드의 경로를 가져와서 확장
|
||||
SELECT path || '/' || NEW.id::text, depth_level + 1
|
||||
INTO NEW.path, NEW.depth_level
|
||||
FROM memo_nodes
|
||||
WHERE id = NEW.parent_id;
|
||||
END IF;
|
||||
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
-- 경로 업데이트 트리거
|
||||
CREATE TRIGGER memo_nodes_path_update
|
||||
BEFORE INSERT OR UPDATE OF parent_id ON memo_nodes
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION update_memo_node_path();
|
||||
|
||||
-- 샘플 데이터 (개발용)
|
||||
-- 소설 템플릿 예시
|
||||
INSERT INTO memo_trees (user_id, title, description, tree_type, template_data)
|
||||
SELECT
|
||||
u.id,
|
||||
'내 첫 번째 소설',
|
||||
'판타지 소설 프로젝트',
|
||||
'novel',
|
||||
'{
|
||||
"genre": "fantasy",
|
||||
"target_length": 100000,
|
||||
"chapters_planned": 20,
|
||||
"main_characters": [],
|
||||
"world_building": {}
|
||||
}'::jsonb
|
||||
FROM users u
|
||||
WHERE u.email = 'admin@test.com'
|
||||
LIMIT 1;
|
||||
98
backend/database/migrations/006_add_canonical_path.sql
Normal file
98
backend/database/migrations/006_add_canonical_path.sql
Normal file
@@ -0,0 +1,98 @@
|
||||
-- 006_add_canonical_path.sql
|
||||
-- 정사 경로 표시를 위한 필드 추가
|
||||
|
||||
-- memo_nodes 테이블에 정사 경로 관련 필드 추가
|
||||
ALTER TABLE memo_nodes
|
||||
ADD COLUMN is_canonical BOOLEAN DEFAULT FALSE,
|
||||
ADD COLUMN canonical_order INTEGER DEFAULT NULL,
|
||||
ADD COLUMN story_path TEXT DEFAULT NULL; -- 정사 경로 저장 (예: /1/3/7)
|
||||
|
||||
-- 정사 경로 순서를 위한 인덱스 추가
|
||||
CREATE INDEX idx_memo_nodes_canonical_order ON memo_nodes(tree_id, canonical_order) WHERE is_canonical = TRUE;
|
||||
|
||||
-- 트리별 정사 경로 통계를 위한 뷰 생성
|
||||
CREATE OR REPLACE VIEW memo_tree_canonical_stats AS
|
||||
SELECT
|
||||
t.id as tree_id,
|
||||
t.title as tree_title,
|
||||
COUNT(n.id) as total_nodes,
|
||||
COUNT(CASE WHEN n.is_canonical = TRUE THEN 1 END) as canonical_nodes,
|
||||
MAX(n.canonical_order) as max_canonical_order,
|
||||
STRING_AGG(
|
||||
CASE WHEN n.is_canonical = TRUE THEN n.title END,
|
||||
' → '
|
||||
ORDER BY n.canonical_order
|
||||
) as canonical_story_path
|
||||
FROM memo_trees t
|
||||
LEFT JOIN memo_nodes n ON t.id = n.tree_id
|
||||
GROUP BY t.id, t.title;
|
||||
|
||||
-- 정사 경로 순서 자동 업데이트 함수 (분기점에서 하나만 선택 가능)
|
||||
CREATE OR REPLACE FUNCTION update_canonical_order()
|
||||
RETURNS TRIGGER AS $$
|
||||
BEGIN
|
||||
-- 정사로 설정될 때
|
||||
IF NEW.is_canonical = TRUE AND (OLD.is_canonical IS NULL OR OLD.is_canonical = FALSE) THEN
|
||||
-- 같은 부모를 가진 다른 형제 노드들의 정사 상태 해제 (분기점에서 하나만 선택)
|
||||
IF NEW.parent_id IS NOT NULL THEN
|
||||
UPDATE memo_nodes
|
||||
SET is_canonical = FALSE, canonical_order = NULL, story_path = NULL
|
||||
WHERE tree_id = NEW.tree_id
|
||||
AND parent_id = NEW.parent_id
|
||||
AND id != NEW.id
|
||||
AND is_canonical = TRUE;
|
||||
END IF;
|
||||
|
||||
-- 부모 노드의 순서를 기준으로 순서 계산
|
||||
IF NEW.parent_id IS NULL THEN
|
||||
-- 루트 노드는 항상 1
|
||||
NEW.canonical_order = 1;
|
||||
ELSE
|
||||
-- 부모 노드의 순서 + 1
|
||||
SELECT COALESCE(parent.canonical_order, 0) + 1
|
||||
INTO NEW.canonical_order
|
||||
FROM memo_nodes parent
|
||||
WHERE parent.id = NEW.parent_id AND parent.is_canonical = TRUE;
|
||||
|
||||
-- 부모가 정사가 아니면 순서 할당 안함
|
||||
IF NEW.canonical_order IS NULL THEN
|
||||
NEW.canonical_order = NULL;
|
||||
END IF;
|
||||
END IF;
|
||||
|
||||
-- 정사 경로 업데이트
|
||||
NEW.story_path = COALESCE(NEW.path, '');
|
||||
END IF;
|
||||
|
||||
-- 정사에서 제외될 때 순서 제거
|
||||
IF NEW.is_canonical = FALSE AND OLD.is_canonical = TRUE THEN
|
||||
NEW.canonical_order = NULL;
|
||||
NEW.story_path = NULL;
|
||||
|
||||
-- 뒤의 순서들을 앞으로 당기기
|
||||
UPDATE memo_nodes
|
||||
SET canonical_order = canonical_order - 1
|
||||
WHERE tree_id = NEW.tree_id
|
||||
AND is_canonical = TRUE
|
||||
AND canonical_order > OLD.canonical_order;
|
||||
END IF;
|
||||
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
-- 트리거 생성
|
||||
DROP TRIGGER IF EXISTS trigger_update_canonical_order ON memo_nodes;
|
||||
CREATE TRIGGER trigger_update_canonical_order
|
||||
BEFORE UPDATE ON memo_nodes
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION update_canonical_order();
|
||||
|
||||
-- 기존 루트 노드들을 정사로 설정 (기본값)
|
||||
UPDATE memo_nodes
|
||||
SET is_canonical = TRUE, canonical_order = 1
|
||||
WHERE parent_id IS NULL AND is_canonical = FALSE;
|
||||
|
||||
COMMENT ON COLUMN memo_nodes.is_canonical IS '정사 경로 여부 (소설의 메인 스토리라인)';
|
||||
COMMENT ON COLUMN memo_nodes.canonical_order IS '정사 경로에서의 순서 (1부터 시작)';
|
||||
COMMENT ON COLUMN memo_nodes.story_path IS '정사 경로 문자열 표현';
|
||||
58
backend/database/migrations/006_create_todo_tables.sql
Normal file
58
backend/database/migrations/006_create_todo_tables.sql
Normal file
@@ -0,0 +1,58 @@
|
||||
-- 할일관리 시스템 테이블 생성
|
||||
|
||||
-- 할일 아이템 테이블
|
||||
CREATE TABLE IF NOT EXISTS todo_items (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
|
||||
-- 기본 정보
|
||||
content TEXT NOT NULL,
|
||||
status VARCHAR(20) NOT NULL DEFAULT 'draft' CHECK (status IN ('draft', 'scheduled', 'active', 'completed', 'delayed', 'split')),
|
||||
|
||||
-- 시간 관리
|
||||
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
|
||||
start_date TIMESTAMP WITH TIME ZONE,
|
||||
estimated_minutes INTEGER CHECK (estimated_minutes > 0 AND estimated_minutes <= 120),
|
||||
completed_at TIMESTAMP WITH TIME ZONE,
|
||||
delayed_until TIMESTAMP WITH TIME ZONE,
|
||||
|
||||
-- 분할 관리
|
||||
parent_id UUID REFERENCES todo_items(id) ON DELETE CASCADE,
|
||||
split_order INTEGER,
|
||||
|
||||
-- 인덱스
|
||||
CONSTRAINT unique_split_order UNIQUE (parent_id, split_order)
|
||||
);
|
||||
|
||||
-- 할일 댓글 테이블
|
||||
CREATE TABLE IF NOT EXISTS todo_comments (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
todo_item_id UUID NOT NULL REFERENCES todo_items(id) ON DELETE CASCADE,
|
||||
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
|
||||
content TEXT NOT NULL,
|
||||
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- 인덱스 생성
|
||||
CREATE INDEX IF NOT EXISTS idx_todo_items_user_id ON todo_items(user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_todo_items_status ON todo_items(status);
|
||||
CREATE INDEX IF NOT EXISTS idx_todo_items_start_date ON todo_items(start_date);
|
||||
CREATE INDEX IF NOT EXISTS idx_todo_items_parent_id ON todo_items(parent_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_todo_comments_todo_item_id ON todo_comments(todo_item_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_todo_comments_user_id ON todo_comments(user_id);
|
||||
|
||||
-- 트리거: updated_at 자동 업데이트
|
||||
CREATE OR REPLACE FUNCTION update_todo_comments_updated_at()
|
||||
RETURNS TRIGGER AS $$
|
||||
BEGIN
|
||||
NEW.updated_at = NOW();
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
CREATE TRIGGER trigger_todo_comments_updated_at
|
||||
BEFORE UPDATE ON todo_comments
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION update_todo_comments_updated_at();
|
||||
97
backend/database/migrations/007_fix_canonical_order.sql
Normal file
97
backend/database/migrations/007_fix_canonical_order.sql
Normal file
@@ -0,0 +1,97 @@
|
||||
-- 007_fix_canonical_order.sql
|
||||
-- 정사 경로 순서 계산 로직 수정
|
||||
|
||||
-- 기존 트리거 삭제
|
||||
DROP TRIGGER IF EXISTS trigger_update_canonical_order ON memo_nodes;
|
||||
DROP FUNCTION IF EXISTS update_canonical_order();
|
||||
|
||||
-- 정사 경로 순서를 올바르게 계산하는 함수
|
||||
CREATE OR REPLACE FUNCTION update_canonical_order()
|
||||
RETURNS TRIGGER AS $$
|
||||
BEGIN
|
||||
-- 정사로 설정될 때
|
||||
IF NEW.is_canonical = TRUE AND (OLD.is_canonical IS NULL OR OLD.is_canonical = FALSE) THEN
|
||||
-- 같은 부모를 가진 다른 형제 노드들의 정사 상태 해제 (분기점에서 하나만 선택)
|
||||
IF NEW.parent_id IS NOT NULL THEN
|
||||
UPDATE memo_nodes
|
||||
SET is_canonical = FALSE, canonical_order = NULL, story_path = NULL
|
||||
WHERE tree_id = NEW.tree_id
|
||||
AND parent_id = NEW.parent_id
|
||||
AND id != NEW.id
|
||||
AND is_canonical = TRUE;
|
||||
END IF;
|
||||
|
||||
-- 정사 경로 업데이트
|
||||
NEW.story_path = COALESCE(NEW.path, '');
|
||||
|
||||
-- 순서는 별도 함수에서 일괄 계산
|
||||
PERFORM recalculate_canonical_orders(NEW.tree_id);
|
||||
END IF;
|
||||
|
||||
-- 정사에서 제외될 때
|
||||
IF NEW.is_canonical = FALSE AND OLD.is_canonical = TRUE THEN
|
||||
NEW.canonical_order = NULL;
|
||||
NEW.story_path = NULL;
|
||||
|
||||
-- 순서 재계산
|
||||
PERFORM recalculate_canonical_orders(NEW.tree_id);
|
||||
END IF;
|
||||
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
-- 트리별 정사 경로 순서를 DFS로 재계산하는 함수
|
||||
CREATE OR REPLACE FUNCTION recalculate_canonical_orders(tree_uuid UUID)
|
||||
RETURNS VOID AS $$
|
||||
DECLARE
|
||||
current_order INTEGER := 1;
|
||||
BEGIN
|
||||
-- 모든 정사 노드의 순서를 NULL로 초기화
|
||||
UPDATE memo_nodes
|
||||
SET canonical_order = NULL
|
||||
WHERE tree_id = tree_uuid AND is_canonical = TRUE;
|
||||
|
||||
-- DFS로 순서 할당 (재귀 CTE 사용)
|
||||
WITH RECURSIVE canonical_path AS (
|
||||
-- 루트 노드들 (정사인 것만)
|
||||
SELECT id, parent_id, title, 1 as order_num, ARRAY[id] as path
|
||||
FROM memo_nodes
|
||||
WHERE tree_id = tree_uuid
|
||||
AND parent_id IS NULL
|
||||
AND is_canonical = TRUE
|
||||
|
||||
UNION ALL
|
||||
|
||||
-- 자식 노드들 (정사인 것만)
|
||||
SELECT n.id, n.parent_id, n.title,
|
||||
cp.order_num + 1 as order_num,
|
||||
cp.path || n.id
|
||||
FROM memo_nodes n
|
||||
INNER JOIN canonical_path cp ON n.parent_id = cp.id
|
||||
WHERE n.tree_id = tree_uuid
|
||||
AND n.is_canonical = TRUE
|
||||
)
|
||||
UPDATE memo_nodes
|
||||
SET canonical_order = cp.order_num
|
||||
FROM canonical_path cp
|
||||
WHERE memo_nodes.id = cp.id;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
-- 트리거 다시 생성
|
||||
CREATE TRIGGER trigger_update_canonical_order
|
||||
AFTER UPDATE ON memo_nodes
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION update_canonical_order();
|
||||
|
||||
-- 기존 데이터의 순서 재계산
|
||||
DO $$
|
||||
DECLARE
|
||||
tree_rec RECORD;
|
||||
BEGIN
|
||||
FOR tree_rec IN SELECT DISTINCT tree_id FROM memo_nodes WHERE is_canonical = TRUE
|
||||
LOOP
|
||||
PERFORM recalculate_canonical_orders(tree_rec.tree_id);
|
||||
END LOOP;
|
||||
END $$;
|
||||
Reference in New Issue
Block a user