REFACTOR(repo): simplify project structure

- Move services/nextjs to nextjs/
- Move deploy/docker/Dockerfile.prod to Dockerfile
- Add GitHub Actions workflows (ci.yml, build.yml)
- Remove deploy/, services/, scripts/ folders
This commit is contained in:
2026-01-05 02:29:10 +09:00
commit e82ea71c22
205 changed files with 21304 additions and 0 deletions

148
.dockerignore Normal file
View File

@@ -0,0 +1,148 @@
# Dependencies
node_modules
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# Next.js
.next/
out/
build/
# Production
dist/
# Environment variables
.env
.env.local
.env.development.local
.env.test.local
.env.production.local
# Logs
logs
*.log
# Runtime data
pids
*.pid
*.seed
*.pid.lock
# Coverage directory used by tools like istanbul
coverage/
*.lcov
# nyc test coverage
.nyc_output
# Dependency directories
jspm_packages/
# Optional npm cache directory
.npm
# Optional eslint cache
.eslintcache
# Microbundle cache
.rpt2_cache/
.rts2_cache_cjs/
.rts2_cache_es/
.rts2_cache_umd/
# Optional REPL history
.node_repl_history
# Output of 'npm pack'
*.tgz
# Yarn Integrity file
.yarn-integrity
# parcel-bundler cache (https://parceljs.org/)
.cache
.parcel-cache
# Next.js build output
.next
# Nuxt.js build / generate output
.nuxt
# Gatsby files
.cache/
public
# Storybook build outputs
.out
.storybook-out
# Temporary folders
tmp/
temp/
# Editor directories and files
.vscode/
.idea/
*.swp
*.swo
*~
# OS generated files
.DS_Store
.DS_Store?
._*
.Spotlight-V100
.Trashes
ehthumbs.db
Thumbs.db
# Git
.git
.gitignore
# Docker
Dockerfile*
docker-compose*
.dockerignore
# Documentation
README.md
*.md
# Backup files
backups/
*.backup
*.bak
# Uploads (should be handled by volumes)
uploads/
# SSL certificates
ssl/
# Nginx config (handled by volumes)
nginx/
# Scripts
scripts/
backup-scripts/
# Prisma
prisma/migrations/
# TypeScript
*.tsbuildinfo
# Testing
coverage/
.nyc_output/
# Misc
.editorconfig
.prettierrc
.eslintrc*
# Trunk
.trunk

73
.github/workflows/build.yml vendored Normal file
View File

@@ -0,0 +1,73 @@
name: Build Docker Image
on:
push:
branches: [main]
tags:
- 'v*'
workflow_dispatch:
env:
REGISTRY: ghcr.io
IMAGE_NAME: ${{ github.repository }}
jobs:
build-and-push:
runs-on: ubuntu-24.04-arm
permissions:
contents: read
packages: write
outputs:
image-tag: ${{ steps.meta.outputs.tags }}
image-digest: ${{ steps.build.outputs.digest }}
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to GitHub Container Registry
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.CR_PAT }}
- name: Lowercase repository name
id: lowercase
run: |
echo "repo=$(echo ${{ github.repository }} | tr '[:upper:]' '[:lower:]')" >> $GITHUB_OUTPUT
- name: Extract metadata (tags, labels)
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ env.REGISTRY }}/${{ steps.lowercase.outputs.repo }}
tags: |
type=sha,prefix=sha-,format=long
type=raw,value=latest,enable={{is_default_branch}}
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
- name: Build and push Docker image
id: build
uses: docker/build-push-action@v5
with:
context: ./nextjs
file: ./Dockerfile
push: true
platforms: linux/arm64
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha
cache-to: type=gha,mode=max
- name: Display image information
run: |
echo "Image built and pushed successfully!"
echo "Image tags:"
echo "${{ steps.meta.outputs.tags }}"
echo "Digest: ${{ steps.build.outputs.digest }}"

45
.github/workflows/ci.yml vendored Normal file
View File

@@ -0,0 +1,45 @@
name: CI
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
lint-and-build:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
cache-dependency-path: nextjs/package-lock.json
- name: Install dependencies
working-directory: nextjs
run: npm ci
- name: Run ESLint
working-directory: nextjs
run: npm run lint
- name: Build Next.js application
working-directory: nextjs
run: npm run build
env:
NEXT_TELEMETRY_DISABLED: 1
- name: Check build output
working-directory: nextjs
run: |
if [ ! -d ".next" ]; then
echo "Build failed: .next directory not found"
exit 1
fi
echo "Build completed successfully"

56
.gitignore vendored Normal file
View File

@@ -0,0 +1,56 @@
# dependencies
node_modules
services/nextjs/node_modules
.pnp
.pnp.*
# testing
coverage
# next.js
services/nextjs/.next/
.next/
/out/
# production
/build
# misc
.DS_Store
*.pem
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.pnpm-debug.log*
# env files
.env*
!.env.example
# vercel
.vercel
# typescript
*.tsbuildinfo
next-env.d.ts
# prisma
/prisma/dev.db
/prisma/dev.db-journal
/prisma/*.db
/prisma/*.db-journal
# IDE
.vscode
.idea
*.swp
*.swo
*~
# OS
Thumbs.db
# trunk
.trunk

62
Dockerfile Normal file
View File

@@ -0,0 +1,62 @@
# Multi-stage build for Next.js application
FROM node:20-alpine AS base
# Install dependencies only when needed
FROM base AS deps
# Check https://github.com/nodejs/docker-node/tree/b4117f9333da4138b03a546ec926ef50a31506c3#nodealpine to understand why libc6-compat might be needed.
RUN apk add --no-cache libc6-compat
WORKDIR /app
# Install dependencies based on the preferred package manager
COPY package.json package-lock.json* ./
RUN npm ci --only=production
# Rebuild the source code only when needed
FROM base AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
# Generate Prisma Client
RUN npx prisma generate
# Build the application
ENV NEXT_TELEMETRY_DISABLED=1
RUN npm run build
# Production image, copy all the files and run next
FROM base AS runner
WORKDIR /app
ENV NODE_ENV=production
ENV NEXT_TELEMETRY_DISABLED=1
RUN addgroup --system --gid 1001 nodejs && adduser --system --uid 1001 nextjs
# Copy built application
COPY --from=builder /app/public ./public
# Set the correct permission for prerender cache
RUN mkdir .next && chown nextjs:nodejs .next
# Automatically leverage output traces to reduce image size
# https://nextjs.org/docs/advanced-features/output-file-tracing
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
# Copy Prisma files
COPY --from=builder /app/prisma ./prisma
COPY --from=builder /app/node_modules/.prisma ./node_modules/.prisma
USER nextjs
EXPOSE 3000
ENV PORT=3000
ENV HOSTNAME=0.0.0.0
# Health check
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
CMD curl -f http://localhost:3000/api/health || exit 1
CMD ["node", "server.js"]

View File

@@ -0,0 +1,61 @@
import { NextRequest, NextResponse } from 'next/server'
import { authenticateUser, generateToken } from '@/shared/lib/auth'
export async function POST(req: NextRequest) {
try {
const { email, password } = await req.json()
// Validation
if (!email || !password) {
return NextResponse.json(
{ error: 'Email and password are required' },
{ status: 400 }
)
}
// Authenticate user
const user = await authenticateUser(email, password)
if (!user) {
return NextResponse.json(
{ error: 'Invalid credentials' },
{ status: 401 }
)
}
// Generate JWT token
const token = generateToken({
userId: user.id,
email: user.email,
})
// Create response with user data
const response = NextResponse.json({
message: 'Login successful',
token,
user: {
id: user.id,
email: user.email,
name: user.name,
image: user.image,
},
})
// Set auth token in cookie (HttpOnly for security)
response.cookies.set('auth-token', token, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax',
maxAge: 60 * 60 * 24 * 7, // 7 days
path: '/',
})
return response
} catch (error) {
console.error('Login error:', error)
return NextResponse.json(
{ error: 'Internal server error' },
{ status: 500 }
)
}
}

View File

@@ -0,0 +1,22 @@
import { NextRequest, NextResponse } from 'next/server'
export async function POST(req: NextRequest) {
try {
// Create response
const response = NextResponse.json({
message: 'Logout successful',
})
// Clear auth token cookie
response.cookies.delete('auth-token')
return response
} catch (error) {
console.error('Logout error:', error)
return NextResponse.json(
{ error: 'Internal server error' },
{ status: 500 }
)
}
}

View File

@@ -0,0 +1,54 @@
import { NextRequest, NextResponse } from 'next/server'
import { verifyToken } from '@/shared/lib/auth'
import { db } from '@/shared/lib/db'
export async function GET(req: NextRequest) {
try {
// Get token from cookie
const token = req.cookies.get('auth-token')?.value
if (!token) {
return NextResponse.json(
{ error: 'Unauthorized' },
{ status: 401 }
)
}
// Verify token
const payload = verifyToken(token)
if (!payload) {
return NextResponse.json(
{ error: 'Invalid token' },
{ status: 401 }
)
}
// Get user from database
const user = await db.user.findUnique({
where: { id: payload.userId },
select: {
id: true,
email: true,
name: true,
image: true,
},
})
if (!user) {
return NextResponse.json(
{ error: 'User not found' },
{ status: 404 }
)
}
return NextResponse.json(user)
} catch (error) {
console.error('Get user error:', error)
return NextResponse.json(
{ error: 'Internal server error' },
{ status: 500 }
)
}
}

View File

@@ -0,0 +1,76 @@
import { NextRequest, NextResponse } from 'next/server'
import { createUser, generateToken } from '@/shared/lib/auth'
import { db } from '@/shared/lib/db'
export async function POST(req: NextRequest) {
try {
const { email, password, name } = await req.json()
// Validation
if (!email || !password) {
return NextResponse.json(
{ error: 'Email and password are required' },
{ status: 400 }
)
}
if (password.length < 6) {
return NextResponse.json(
{ error: 'Password must be at least 6 characters' },
{ status: 400 }
)
}
// Check if user already exists
const existingUser = await db.user.findUnique({
where: { email },
})
if (existingUser) {
return NextResponse.json(
{ error: 'User already exists' },
{ status: 400 }
)
}
// Create user
const user = await createUser(email, password, name)
// Generate JWT token
const token = generateToken({
userId: user.id,
email: user.email,
})
// Create response with user data
const response = NextResponse.json(
{
message: 'User created successfully',
token,
user: {
id: user.id,
email: user.email,
name: user.name,
}
},
{ status: 201 }
)
// Set auth token in cookie (HttpOnly for security)
response.cookies.set('auth-token', token, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax',
maxAge: 60 * 60 * 24 * 7, // 7 days
path: '/',
})
return response
} catch (error) {
console.error('Registration error:', error)
return NextResponse.json(
{ error: 'Internal server error' },
{ status: 500 }
)
}
}

View File

@@ -0,0 +1,49 @@
import { NextRequest, NextResponse } from 'next/server'
import { db } from '@/shared/lib/db'
// GET /api/documents/[id]/public - Get a public document (no auth required)
export async function GET(req: NextRequest) {
try {
const url = new URL(req.url)
const id = url.pathname.split('/')[3] // Extract ID from /api/documents/[id]/public
if (!id) {
return NextResponse.json(
{ error: 'Document ID is required' },
{ status: 400 }
)
}
// 공개된 문서만 조회 (인증 없이)
const document = await db.document.findFirst({
where: {
id,
isPublished: true, // 공개된 문서만
isArchived: false,
},
include: {
user: {
select: {
name: true,
email: true,
}
}
}
})
if (!document) {
return NextResponse.json(
{ error: 'Document not found or not published' },
{ status: 404 }
)
}
return NextResponse.json(document)
} catch (error) {
console.error('Error fetching public document:', error)
return NextResponse.json(
{ error: 'Failed to fetch document' },
{ status: 500 }
)
}
}

View File

@@ -0,0 +1,202 @@
import { NextRequest, NextResponse } from 'next/server'
import { withAuth } from '@/shared/lib/middleware'
import { db } from '@/shared/lib/db'
import { deleteFile } from '@/shared/lib/s3'
// GET /api/documents/[id] - Get a specific document
export const GET = withAuth(async (req: NextRequest, userId: string) => {
try {
const url = new URL(req.url)
const id = url.pathname.split('/').pop()
if (!id) {
return NextResponse.json(
{ error: 'Document ID is required' },
{ status: 400 }
)
}
// Allow viewing both archived and non-archived documents
const document = await db.document.findFirst({
where: {
id,
userId,
},
})
if (!document) {
return NextResponse.json(
{ error: 'Document not found' },
{ status: 404 }
)
}
return NextResponse.json(document)
} catch (error) {
console.error('Error fetching document:', error)
return NextResponse.json(
{ error: 'Failed to fetch document' },
{ status: 500 }
)
}
})
// PUT /api/documents/[id] - Update a specific document
export const PUT = withAuth(async (req: NextRequest, userId: string) => {
try {
const url = new URL(req.url)
const id = url.pathname.split('/').pop()
const { title, content, icon, cover, isPublished } = await req.json()
if (!id) {
return NextResponse.json(
{ error: 'Document ID is required' },
{ status: 400 }
)
}
// Check if document exists and belongs to user
const existingDocument = await db.document.findFirst({
where: {
id,
userId,
isArchived: false,
},
})
if (!existingDocument) {
return NextResponse.json(
{ error: 'Document not found' },
{ status: 404 }
)
}
const document = await db.document.update({
where: { id },
data: {
...(title && { title }),
...(content && { content }),
...(icon !== undefined && { icon }),
...(cover !== undefined && { cover }),
...(isPublished !== undefined && { isPublished }),
updatedAt: new Date(),
},
})
return NextResponse.json(document)
} catch (error) {
console.error('Error updating document:', error)
return NextResponse.json(
{ error: 'Failed to update document' },
{ status: 500 }
)
}
})
// Helper function to extract image paths from document content
async function extractImagePaths(content: any): Promise<string[]> {
if (!content) return [];
const paths: string[] = [];
try {
const parsed = typeof content === 'string' ? JSON.parse(content) : content;
// Recursive function to traverse the document structure
const traverse = (node: any) => {
if (!node) return;
// Check if this is an imageUpload node with a path
if (node.type === 'imageUpload' && node.attrs?.path) {
paths.push(node.attrs.path);
}
// Recursively check content and children
if (node.content && Array.isArray(node.content)) {
node.content.forEach(traverse);
}
if (node.children && Array.isArray(node.children)) {
node.children.forEach(traverse);
}
};
// Check if parsed is an object with content
if (parsed.content) {
traverse(parsed);
} else if (Array.isArray(parsed)) {
parsed.forEach(traverse);
} else if (parsed.type) {
traverse(parsed);
}
} catch (error) {
console.error('Error parsing document content:', error);
}
return paths;
}
// DELETE /api/documents/[id] - Archive a specific document
export const DELETE = withAuth(async (req: NextRequest, userId: string) => {
try {
const url = new URL(req.url)
const id = url.pathname.split('/').pop()
if (!id) {
return NextResponse.json(
{ error: 'Document ID is required' },
{ status: 400 }
)
}
// Check if document exists and belongs to user
const existingDocument = await db.document.findFirst({
where: {
id,
userId,
isArchived: false,
},
})
if (!existingDocument) {
return NextResponse.json(
{ error: 'Document not found' },
{ status: 404 }
)
}
// Extract and delete all image files from the document
const imagePaths = await extractImagePaths(existingDocument.content);
if (imagePaths.length > 0) {
console.log(`Deleting ${imagePaths.length} image files from document ${id}`);
// Delete all image files
await Promise.allSettled(
imagePaths.map(path => {
try {
return deleteFile(path);
} catch (error) {
console.error(`Failed to delete image file ${path}:`, error);
return Promise.resolve();
}
})
);
}
// Archive the document instead of deleting it
const document = await db.document.update({
where: { id },
data: {
isArchived: true,
updatedAt: new Date(),
},
})
return NextResponse.json({ message: 'Document archived successfully' })
} catch (error) {
console.error('Error archiving document:', error)
return NextResponse.json(
{ error: 'Failed to archive document' },
{ status: 500 }
)
}
})

View File

@@ -0,0 +1,84 @@
import { NextRequest, NextResponse } from 'next/server'
import { withAuth } from '@/shared/lib/middleware'
import { db } from '@/shared/lib/db'
// GET /api/documents - Get all documents for the authenticated user
export const GET = withAuth(async (req: NextRequest, userId: string) => {
try {
const url = new URL(req.url)
const folderId = url.searchParams.get('folderId')
const documents = await db.document.findMany({
where: {
userId,
isArchived: false,
...(folderId && { folderId }),
},
include: {
folder: {
select: {
id: true,
name: true,
icon: true,
},
},
},
orderBy: {
createdAt: 'desc',
},
})
return NextResponse.json(documents)
} catch (error) {
console.error('Error fetching documents:', error)
return NextResponse.json(
{ error: 'Failed to fetch documents' },
{ status: 500 }
)
}
})
// POST /api/documents - Create a new document
export const POST = withAuth(async (req: NextRequest, userId: string) => {
try {
const { title, parentId, folderId } = await req.json()
if (!title) {
return NextResponse.json(
{ error: 'Title is required' },
{ status: 400 }
)
}
const document = await db.document.create({
data: {
title,
userId,
parentId: parentId || null,
folderId: folderId || null,
content: {
type: 'doc',
content: [
{
type: 'paragraph',
content: [
{
type: 'text',
text: 'Start writing...',
},
],
},
],
},
},
})
return NextResponse.json(document, { status: 201 })
} catch (error) {
console.error('Error creating document:', error)
return NextResponse.json(
{ error: 'Failed to create document' },
{ status: 500 }
)
}
})

View File

@@ -0,0 +1,46 @@
import { NextRequest, NextResponse } from 'next/server';
import { GetObjectCommand } from '@aws-sdk/client-s3';
import { getSignedUrl } from '@aws-sdk/s3-request-presigner';
import { S3_CONFIG, s3Client } from '@/shared/config/s3';
// 파일 다운로드 URL 생성
const generateDownloadUrl = async (fileKey: string): Promise<string> => {
try {
const command = new GetObjectCommand({
Bucket: S3_CONFIG.BUCKET_NAME,
Key: fileKey,
});
const downloadUrl = await getSignedUrl(s3Client, command, { expiresIn: 3600 });
return downloadUrl;
} catch (error) {
throw new Error('다운로드 URL 생성에 실패했습니다.');
}
};
export async function POST(request: NextRequest) {
try {
const body = await request.json();
const { fileKey } = body;
if (!fileKey) {
return NextResponse.json(
{ error: 'fileKey가 필요합니다.' },
{ status: 400 }
);
}
const downloadUrl = await generateDownloadUrl(fileKey);
return NextResponse.json({
downloadUrl,
});
} catch (error: any) {
return NextResponse.json(
{ error: error.message || '다운로드 URL 생성에 실패했습니다.' },
{ status: 500 }
);
}
}

View File

@@ -0,0 +1,264 @@
import { NextRequest, NextResponse } from 'next/server'
import { withAuth } from '@/shared/lib/middleware'
import { db } from '@/shared/lib/db'
// GET /api/folders/[id] - Get a specific folder
export const GET = withAuth(async (req: NextRequest, userId: string) => {
try {
const url = new URL(req.url)
const id = url.pathname.split('/').pop()
if (!id) {
return NextResponse.json(
{ error: 'Folder ID is required' },
{ status: 400 }
)
}
const folder = await db.folder.findFirst({
where: {
id,
userId,
isArchived: false,
},
include: {
documents: {
where: {
isArchived: false,
},
select: {
id: true,
title: true,
icon: true,
updatedAt: true,
},
orderBy: {
updatedAt: 'desc',
},
},
children: {
where: {
isArchived: false,
},
orderBy: {
createdAt: 'desc',
},
},
parent: {
select: {
id: true,
name: true,
},
},
_count: {
select: {
documents: {
where: {
isArchived: false,
},
},
children: {
where: {
isArchived: false,
},
},
},
},
},
})
if (!folder) {
return NextResponse.json(
{ error: 'Folder not found' },
{ status: 404 }
)
}
return NextResponse.json(folder)
} catch (error) {
console.error('Error fetching folder:', error)
return NextResponse.json(
{ error: 'Failed to fetch folder' },
{ status: 500 }
)
}
})
// PUT /api/folders/[id] - Update a specific folder
export const PUT = withAuth(async (req: NextRequest, userId: string) => {
try {
const url = new URL(req.url)
const id = url.pathname.split('/').pop()
const { name, parentId, icon, color } = await req.json()
if (!id) {
return NextResponse.json(
{ error: 'Folder ID is required' },
{ status: 400 }
)
}
// Check if folder exists and belongs to user
const existingFolder = await db.folder.findFirst({
where: {
id,
userId,
isArchived: false,
},
})
if (!existingFolder) {
return NextResponse.json(
{ error: 'Folder not found' },
{ status: 404 }
)
}
const folder = await db.folder.update({
where: { id },
data: {
...(name && { name }),
...(parentId !== undefined && { parentId: parentId || null }),
...(icon !== undefined && { icon }),
...(color !== undefined && { color }),
updatedAt: new Date(),
},
include: {
documents: {
where: {
isArchived: false,
},
select: {
id: true,
title: true,
icon: true,
updatedAt: true,
},
orderBy: {
updatedAt: 'desc',
},
},
children: {
where: {
isArchived: false,
},
orderBy: {
createdAt: 'desc',
},
},
parent: {
select: {
id: true,
name: true,
},
},
_count: {
select: {
documents: {
where: {
isArchived: false,
},
},
children: {
where: {
isArchived: false,
},
},
},
},
},
})
return NextResponse.json(folder)
} catch (error) {
console.error('Error updating folder:', error)
return NextResponse.json(
{ error: 'Failed to update folder' },
{ status: 500 }
)
}
})
// DELETE /api/folders/[id] - Archive a specific folder
export const DELETE = withAuth(async (req: NextRequest, userId: string) => {
try {
const url = new URL(req.url)
const id = url.pathname.split('/').pop()
if (!id) {
return NextResponse.json(
{ error: 'Folder ID is required' },
{ status: 400 }
)
}
// Check if folder exists and belongs to user
const existingFolder = await db.folder.findFirst({
where: {
id,
userId,
isArchived: false,
},
})
if (!existingFolder) {
return NextResponse.json(
{ error: 'Folder not found' },
{ status: 404 }
)
}
// Recursively archive the folder and all its contents
const archiveFolderRecursively = async (folderId: string, tx: any) => {
// Archive all documents in this folder
await tx.document.updateMany({
where: {
folderId: folderId,
isArchived: false,
},
data: {
isArchived: true,
updatedAt: new Date(),
},
})
// Get all child folders
const childFolders = await tx.folder.findMany({
where: {
parentId: folderId,
isArchived: false,
},
select: {
id: true,
},
})
// Recursively archive all child folders
for (const childFolder of childFolders) {
await archiveFolderRecursively(childFolder.id, tx)
}
// Archive this folder
await tx.folder.update({
where: { id: folderId },
data: {
isArchived: true,
updatedAt: new Date(),
},
})
}
// Archive the folder and all its contents
await db.$transaction(async (tx) => {
await archiveFolderRecursively(id, tx)
})
return NextResponse.json({ message: 'Folder archived successfully' })
} catch (error) {
console.error('Error archiving folder:', error)
return NextResponse.json(
{ error: 'Failed to archive folder' },
{ status: 500 }
)
}
})

View File

@@ -0,0 +1,154 @@
import { NextRequest, NextResponse } from 'next/server'
import { withAuth } from '@/shared/lib/middleware'
import { db } from '@/shared/lib/db'
// GET /api/folders - Get all folders for the authenticated user
export const GET = withAuth(async (req: NextRequest, userId: string) => {
try {
const folders = await db.folder.findMany({
where: {
userId,
isArchived: false,
},
include: {
documents: {
where: {
isArchived: false,
},
select: {
id: true,
title: true,
icon: true,
updatedAt: true,
},
},
children: {
where: {
isArchived: false,
},
include: {
_count: {
select: {
documents: {
where: {
isArchived: false,
},
},
children: {
where: {
isArchived: false,
},
},
},
},
},
},
_count: {
select: {
documents: {
where: {
isArchived: false,
},
},
children: {
where: {
isArchived: false,
},
},
},
},
},
orderBy: {
createdAt: 'desc',
},
})
return NextResponse.json(folders)
} catch (error) {
console.error('Error fetching folders:', error)
return NextResponse.json(
{ error: 'Failed to fetch folders' },
{ status: 500 }
)
}
})
// POST /api/folders - Create a new folder
export const POST = withAuth(async (req: NextRequest, userId: string) => {
try {
const { name, parentId, icon, color } = await req.json()
if (!name) {
return NextResponse.json(
{ error: 'Folder name is required' },
{ status: 400 }
)
}
const folder = await db.folder.create({
data: {
name,
userId,
parentId: parentId || null,
icon: icon || '📁',
color: color || null,
},
include: {
documents: {
where: {
isArchived: false,
},
select: {
id: true,
title: true,
icon: true,
updatedAt: true,
},
},
children: {
where: {
isArchived: false,
},
include: {
_count: {
select: {
documents: {
where: {
isArchived: false,
},
},
children: {
where: {
isArchived: false,
},
},
},
},
},
},
_count: {
select: {
documents: {
where: {
isArchived: false,
},
},
children: {
where: {
isArchived: false,
},
},
},
},
},
})
return NextResponse.json(folder, { status: 201 })
} catch (error) {
console.error('Error creating folder:', error)
return NextResponse.json(
{ error: 'Failed to create folder' },
{ status: 500 }
)
}
})

View File

@@ -0,0 +1,27 @@
import { NextResponse } from 'next/server';
import { db } from '@/shared/lib/db';
export async function GET() {
try {
// Check database connectivity
await db.$queryRaw`SELECT 1`;
return NextResponse.json({
status: 'healthy',
timestamp: new Date().toISOString(),
uptime: process.uptime(),
environment: process.env.NODE_ENV,
version: process.env.npm_package_version || '1.0.0',
database: 'connected'
});
} catch (error) {
return NextResponse.json(
{
status: 'unhealthy',
timestamp: new Date().toISOString(),
error: 'Database connection failed'
},
{ status: 503 }
);
}
}

View File

@@ -0,0 +1,22 @@
import { NextRequest, NextResponse } from 'next/server';
import { extractBookmarkMetadata } from '@/shared/lib/metadata-extractor';
export async function POST(request: NextRequest) {
try {
const { url } = await request.json();
if (!url) {
return NextResponse.json({ error: 'URL is required' }, { status: 400 });
}
const metadata = await extractBookmarkMetadata(url);
return NextResponse.json(metadata);
} catch (error) {
console.error('Error in metadata API:', error);
return NextResponse.json(
{ error: 'Failed to fetch metadata' },
{ status: 500 }
);
}
}

View File

@@ -0,0 +1,105 @@
import { NextRequest, NextResponse } from 'next/server';
import { prisma } from '@/shared/lib/prisma';
import { withAuth } from '@/shared/lib/middleware';
async function createTemplate(request: NextRequest, userId: string) {
try {
const { name, description, category, title, content } = await request.json();
if (!name || !title || !content) {
return NextResponse.json(
{ error: 'Template name, title, and content are required' },
{ status: 400 }
);
}
// Verify user exists in database
const user = await prisma.user.findUnique({
where: { id: userId }
});
if (!user) {
return NextResponse.json(
{ error: 'User not found' },
{ status: 404 }
);
}
// Save template to database (always private to the user)
const template = await prisma.template.create({
data: {
name,
description: description || '',
category: category || 'General',
title,
content,
isPublic: false, // Always private
userId,
},
});
console.log('Template created:', template);
return NextResponse.json({
success: true,
template,
message: 'Template created successfully'
});
} catch (error) {
console.error('Error creating template:', error);
return NextResponse.json(
{ error: 'Failed to create template' },
{ status: 500 }
);
}
}
export const POST = withAuth(createTemplate);
async function getTemplates(request: NextRequest, userId: string) {
try {
// Verify user exists in database
const user = await prisma.user.findUnique({
where: { id: userId }
});
if (!user) {
return NextResponse.json(
{ error: 'User not found' },
{ status: 404 }
);
}
// Fetch only user's own templates
const templates = await prisma.template.findMany({
where: {
userId // Only user's own templates
},
orderBy: {
createdAt: 'desc'
},
select: {
id: true,
name: true,
description: true,
category: true,
title: true,
content: true,
isPublic: true,
createdAt: true,
updatedAt: true,
userId: true,
}
});
return NextResponse.json({ templates });
} catch (error) {
console.error('Error fetching templates:', error);
return NextResponse.json(
{ error: 'Failed to fetch templates' },
{ status: 500 }
);
}
}
export const GET = withAuth(getTemplates);

View File

@@ -0,0 +1,57 @@
import { NextRequest, NextResponse } from 'next/server';
import { PutObjectCommand } from '@aws-sdk/client-s3';
import { getSignedUrl } from '@aws-sdk/s3-request-presigner';
import { S3_CONFIG, s3Client } from '@/shared/config/s3';
// 파일 업로드 URL 생성
const generateUploadUrl = async (
fileName: string,
fileType: string,
folder?: string
): Promise<{ uploadUrl: string; fileKey: string }> => {
try {
const fileKey = folder ? `${folder}/${Date.now()}-${fileName}` : `${Date.now()}-${fileName}`;
const command = new PutObjectCommand({
Bucket: S3_CONFIG.BUCKET_NAME,
Key: fileKey,
ContentType: fileType,
});
const uploadUrl = await getSignedUrl(s3Client, command, { expiresIn: 3600 });
return {
uploadUrl,
fileKey,
};
} catch (error: any) {
throw new Error(`업로드 URL 생성에 실패했습니다: ${error.message}`);
}
};
export async function POST(request: NextRequest) {
try {
const body = await request.json();
const { fileName, fileType, folder } = body;
if (!fileName || !fileType) {
return NextResponse.json(
{ error: 'fileName과 fileType이 필요합니다.' },
{ status: 400 }
);
}
const { uploadUrl, fileKey } = await generateUploadUrl(fileName, fileType, folder || 'jotion-uploads');
return NextResponse.json({
uploadUrl,
fileKey,
});
} catch (error: any) {
return NextResponse.json(
{ error: error.message || '업로드 URL 생성에 실패했습니다.' },
{ status: 500 }
);
}
}

View File

@@ -0,0 +1,177 @@
import { NextRequest, NextResponse } from 'next/server';
import { uploadFileServer, deleteFile } from '@/shared/lib/s3';
import { cookies } from 'next/headers';
import { verifyToken } from '@/shared/lib/auth';
// File size limits (in bytes)
const MAX_FILE_SIZE = {
image: 10 * 1024 * 1024, // 10MB
audio: 50 * 1024 * 1024, // 50MB
document: 20 * 1024 * 1024, // 20MB
};
// Allowed file types
const ALLOWED_TYPES = {
image: ['image/jpeg', 'image/png', 'image/gif', 'image/webp', 'image/svg+xml'],
audio: ['audio/mpeg', 'audio/wav', 'audio/ogg', 'audio/mp4', 'audio/webm'],
document: [
'application/pdf',
'application/msword',
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
'application/vnd.ms-excel',
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
'text/plain',
'text/csv',
],
};
export async function POST(request: NextRequest) {
try {
// Verify authentication - check cookie
const cookieStore = await cookies();
const cookieToken = cookieStore.get('auth-token')?.value;
const token = cookieToken;
if (!token) {
console.error('[Upload] No token found in Authorization header or cookie');
return NextResponse.json(
{ error: 'Unauthorized' },
{ status: 401 }
);
}
const decoded = verifyToken(token);
if (!decoded) {
console.error('[Upload] Token verification failed');
return NextResponse.json(
{ error: 'Invalid token' },
{ status: 401 }
);
}
console.log('[Upload] Token verified successfully for user:', decoded.userId);
// Parse form data
const formData = await request.formData();
const file = formData.get('file') as File;
const fileType = formData.get('type') as 'images' | 'audio' | 'documents';
if (!file) {
return NextResponse.json(
{ error: 'No file provided' },
{ status: 400 }
);
}
// Determine folder based on file type
let folder: 'images' | 'audio' | 'documents' = 'documents';
let maxSize = MAX_FILE_SIZE.document;
let allowedTypes = ALLOWED_TYPES.document;
if (fileType === 'images') {
folder = 'images';
maxSize = MAX_FILE_SIZE.image;
allowedTypes = ALLOWED_TYPES.image;
} else if (fileType === 'audio') {
folder = 'audio';
maxSize = MAX_FILE_SIZE.audio;
allowedTypes = ALLOWED_TYPES.audio;
} else if (file.type.startsWith('image/')) {
folder = 'images';
maxSize = MAX_FILE_SIZE.image;
allowedTypes = ALLOWED_TYPES.image;
} else if (file.type.startsWith('audio/')) {
folder = 'audio';
maxSize = MAX_FILE_SIZE.audio;
allowedTypes = ALLOWED_TYPES.audio;
}
// Validate file size
if (file.size > maxSize) {
return NextResponse.json(
{ error: `File size exceeds maximum limit of ${maxSize / 1024 / 1024}MB` },
{ status: 400 }
);
}
// Validate file type
if (!allowedTypes.includes(file.type)) {
return NextResponse.json(
{ error: `File type ${file.type} is not allowed` },
{ status: 400 }
);
}
// Upload file to S3
const { url, path } = await uploadFileServer(file, file.name, file.type, folder);
return NextResponse.json({
success: true,
url,
path,
fileName: file.name,
fileSize: file.size,
fileType: file.type,
});
} catch (error) {
console.error('Upload error:', error);
return NextResponse.json(
{ error: error instanceof Error ? error.message : 'Failed to upload file' },
{ status: 500 }
);
}
}
// DELETE /api/upload - Delete a file from storage
export async function DELETE(request: NextRequest) {
try {
// Verify authentication
const authHeader = request.headers.get('Authorization');
const cookieStore = await cookies();
const cookieToken = cookieStore.get('auth-token')?.value;
const token = authHeader?.startsWith('Bearer ')
? authHeader.substring(7)
: cookieToken;
if (!token) {
return NextResponse.json(
{ error: 'Unauthorized' },
{ status: 401 }
);
}
const decoded = verifyToken(token);
if (!decoded) {
return NextResponse.json(
{ error: 'Invalid token' },
{ status: 401 }
);
}
const { searchParams } = new URL(request.url);
const filePath = searchParams.get('path');
if (!filePath) {
return NextResponse.json(
{ error: 'File path is required' },
{ status: 400 }
);
}
// Delete file from S3
await deleteFile(filePath);
return NextResponse.json({
success: true,
message: 'File deleted successfully',
});
} catch (error) {
console.error('Delete file error:', error);
return NextResponse.json(
{ error: error instanceof Error ? error.message : 'Failed to delete file' },
{ status: 500 }
);
}
}

View File

@@ -0,0 +1,296 @@
"use client";
import { useAuth } from "@/src/app/providers/auth-provider";
import {
useDocumentData,
useDocumentSave,
useDocumentActions,
useDocumentTemplates,
useDocumentUtils,
useDocumentHeadings,
useSidebarSearch
} from "@/features/document-edit/model";
import { Button } from "@/shared/ui/button";
import { Spinner } from "@/shared/ui/spinner";
import { RichTextEditor } from "@/widgets/editor/editor/core/rich-text-editor";
import { DocumentSidebar } from "@/widgets/editor/sidebar/document-sidebar";
import { ArrowLeft, Save, Clock, User, Eye, BookOpen, FileText, Calendar } from "lucide-react";
import { useState } from "react";
import { useRouter, useParams } from "next/navigation";
import { DocumentDetailSkeleton } from "@/shared/ui/skeleton";
const DocumentPage = () => {
const { user } = useAuth();
const router = useRouter();
const params = useParams();
const documentId = params.id as string;
// 1. 문서 데이터 관리
const {
document,
isLoading,
title,
setTitle,
content,
setContent,
availableDocuments,
updateDocument,
} = useDocumentData({ documentId });
// 2. 저장 기능
const {
saveDocument,
isSaving,
isAutoSaving,
lastSaved,
} = useDocumentSave({
documentId,
title,
content,
onSaveSuccess: updateDocument,
});
// 3. 문서 액션
const {
deleteDocument,
shareDocument,
unshareDocument,
isDeleting,
} = useDocumentActions({
documentId,
onPublishChange: (isPublished) => {
if (document) {
updateDocument({ ...document, isPublished });
}
},
});
// 4. 템플릿 기능
const {
createTemplate,
applyTemplate,
} = useDocumentTemplates({
onApply: (templateContent, templateTitle) => {
setContent(templateContent);
if (templateTitle && !title.trim()) {
setTitle(templateTitle);
}
},
});
// 5. 유틸리티
const { getWordCount, formatDate } = useDocumentUtils();
const [showWordCount, setShowWordCount] = useState(false);
if (isLoading) {
return <DocumentDetailSkeleton />;
}
if (!document) {
return (
<div className="h-full flex items-center justify-center bg-gray-50 dark:bg-[#1F1F1F]">
<div className="text-center max-w-md mx-auto">
<div className="w-20 h-20 bg-red-100 rounded-2xl flex items-center justify-center mx-auto mb-6">
<FileText className="h-10 w-10 text-red-600" />
</div>
<h2 className="text-2xl font-bold text-gray-900 dark:text-white mb-3">Document not found</h2>
<p className="text-gray-600 dark:text-gray-400 mb-8 leading-relaxed">
The document you're looking for doesn't exist or you don't have access to it.
</p>
<Button
onClick={() => router.push("/documents")}
className="bg-blue-600 dark:bg-blue-700 text-white hover:bg-blue-700 dark:hover:bg-blue-800"
>
<ArrowLeft className="h-4 w-4 mr-2" />
Back to Documents
</Button>
</div>
</div>
);
}
return (
<div className="h-full flex flex-col bg-gray-50 dark:bg-[#1F1F1F]">
{/* Modern header */}
<div className="bg-secondary border-b border-gray-200 dark:border-gray-700 sticky h-16 top-0 z-10">
<div className="px-4 sm:px-6 py-3 sm:py-4">
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3 sm:gap-0">
{/* Breadcrumb */}
<div className="flex items-center gap-2 sm:gap-3 min-w-0">
<Button
variant="ghost"
size="sm"
onClick={() => router.push("/documents")}
className="text-gray-600 dark:text-white hover:text-gray-900 dark:hover:text-gray-300 flex-shrink-0"
>
<ArrowLeft className="h-4 w-4 mr-1 sm:mr-2" />
<span className="hidden sm:inline">Back</span>
</Button>
<div className="flex items-center gap-1 sm:gap-2 text-xs sm:text-sm text-gray-500 dark:text-gray-300 min-w-0">
<span className="hidden sm:inline">{user?.name ? `${user.name}'s` : "My"} Workspace</span>
<span className="hidden sm:inline">/</span>
{document.folder && (
<>
<span className="hidden md:inline truncate max-w-20">{document.folder.name}</span>
<span className="hidden md:inline">/</span>
</>
)}
<span className="text-gray-900 dark:text-white font-medium truncate">{title || "Untitled"}</span>
</div>
</div>
{/* Status and actions */}
<div className="flex items-center justify-between sm:justify-end gap-2 sm:gap-3">
{/* Auto-save status */}
<div className="flex items-center gap-2 text-xs sm:text-sm text-gray-500 dark:text-gray-300">
{isAutoSaving ? (
<>
<Spinner size="sm" />
<span className="hidden sm:inline">Saving...</span>
</>
) : lastSaved ? (
<>
<Clock className="h-3 w-3" />
<span className="hidden sm:inline">Saved {formatDate(lastSaved)}</span>
</>
) : null}
</div>
{/* Word count - Hidden on very small screens */}
<Button
variant="ghost"
size="sm"
onClick={() => setShowWordCount(!showWordCount)}
className="text-gray-500 dark:text-gray-300 hover:text-gray-700 dark:hover:text-white hidden sm:flex"
>
<FileText className="h-3 w-3 mr-1" />
{getWordCount(content)} words
</Button>
{/* Actions */}
<div className="flex items-center gap-1 sm:gap-2">
<Button
variant="outline"
size="sm"
onClick={saveDocument}
disabled={isSaving}
className="hidden sm:flex border-gray-300 dark:border-gray-600 hover:bg-gray-50 dark:hover:bg-gray-700 text-gray-900 dark:text-white"
>
{isSaving ? (
<>
<Spinner size="sm" />
<span className="ml-2">Saving...</span>
</>
) : (
<>
<Save className="h-4 w-4 mr-2" />
Save
</>
)}
</Button>
{/* Mobile save button */}
<Button
variant="outline"
size="sm"
onClick={saveDocument}
disabled={isSaving}
className="sm:hidden border-gray-300 dark:border-gray-600 hover:bg-gray-50 dark:hover:bg-gray-700 text-gray-900 dark:text-white"
>
{isSaving ? (
<Spinner size="sm" />
) : (
<Save className="h-4 w-4" />
)}
</Button>
</div>
</div>
</div>
</div>
</div>
{/* Main content area - Responsive layout */}
<div className="flex-1 flex flex-col lg:flex-row overflow-hidden">
{/* Document editor - Full width on mobile/tablet, left side on desktop */}
<div className="flex-1 overflow-auto order-2 lg:order-1">
<div className="w-full/ max-w-5xl mx-auto px-4 sm:px-6 py-4 sm:py-8">
{/* Document header */}
<div className="mb-6 sm:mb-8">
{/* Document icon and title */}
<div className="flex items-start sm:items-center gap-3 sm:gap-4 mb-4 sm:mb-6">
<div className="w-10 h-10 sm:w-12 sm:h-12 bg-gray-100 dark:bg-gray-700 rounded-xl flex items-center justify-center flex-shrink-0">
{document.icon ? (
<span className="text-lg sm:text-xl">{document.icon}</span>
) : (
<FileText className="h-5 w-5 sm:h-6 sm:w-6 text-black dark:text-white" />
)}
</div>
<div className="flex-1 min-w-0">
<input
type="text"
value={title}
onChange={(e) => setTitle(e.target.value)}
className="w-full text-2xl sm:text-3xl lg:text-4xl font-bold text-gray-900 dark:text-white bg-transparent border-none outline-none placeholder-gray-400 dark:placeholder-gray-500"
placeholder="Untitled"
/>
<div className="flex flex-col sm:flex-row sm:items-center gap-2 sm:gap-4 mt-2 text-xs sm:text-sm text-gray-500 dark:text-gray-400">
<div className="flex items-center gap-1">
<User className="h-3 w-3" />
<span>{user?.name || "Anonymous"}</span>
</div>
<div className="flex items-center gap-1">
<Calendar className="h-3 w-3" />
<span>Created {new Date(document.createdAt).toLocaleDateString()}</span>
</div>
{document.isPublished && (
<button
onClick={() => window.open(`/home/share/${documentId}`, '_blank')}
className="flex items-center gap-1 text-green-600 dark:text-green-400 hover:text-green-700 dark:hover:text-green-300 transition-colors cursor-pointer hover:underline"
title="공유된 페이지 보기"
>
<Eye className="h-3 w-3" />
<span>Published</span>
</button>
)}
</div>
</div>
</div>
</div>
{/* Editor */}
<div className="bg-white dark:bg-secondary rounded-lg sm:rounded-xl border border-gray-200 dark:border-gray-700 p-4 sm:p-6 lg:p-8 shadow-sm">
<RichTextEditor
content={content}
onChange={setContent}
placeholder="Start writing your document..."
editable={true}
availableDocuments={availableDocuments}
/>
</div>
</div>
</div>
{/* Right panel - Document sidebar - Hidden on mobile/tablet, shown on desktop */}
<div className="hidden lg:block lg:w-80 lg:flex-shrink-0 order-1 lg:order-2">
<DocumentSidebar
content={content}
title={title}
lastSaved={lastSaved || undefined}
wordCount={getWordCount(content)}
documentId={documentId}
published={document?.isPublished || false}
onShare={shareDocument}
onUnshare={unshareDocument}
onCreateTemplate={createTemplate}
onApplyTemplate={applyTemplate}
/>
</div>
</div>
</div>
);
};
export default DocumentPage;

View File

@@ -0,0 +1,45 @@
"use client";
import { Spinner } from "@/shared/ui/spinner";
import { useRouter } from "next/navigation";
import { Navigation } from "@/widgets/documents";
import { useAuth } from "@/src/app/providers/auth-provider";
import { useEffect } from "react";
const MainLayout = ({ children }: { children: React.ReactNode }) => {
const { user, isLoading } = useAuth();
const router = useRouter();
useEffect(() => {
if (!isLoading && !user) {
router.push("/");
}
}, [isLoading, user, router]);
if (isLoading) {
return (
<div className="h-full flex items-center justify-center">
<Spinner size="lg" />
</div>
);
}
if (!user) {
return (
<div className="h-full flex items-center justify-center">
<Spinner size="lg" />
</div>
);
}
return (
<div className="h-full flex dark:bg-[#1F1F1F]">
<Navigation/>
<main className="flex-1 h-full overflow-y-auto">
{children}
</main>
</div>
);
};
export default MainLayout;

View File

@@ -0,0 +1,202 @@
"use client";
import { useEffect, useState } from "react";
import { useSearchParams } from "next/navigation";
import { useAuth } from "@/src/app/providers/auth-provider";
import { useDocumentManagementStore } from "@/features/document-management/model/store";
import { useFolderManagementStore } from "@/features/folder-management/model/store";
import { useDocumentStore } from "@/entities/document/model/store";
import { useFolderStore } from "@/entities/folder/model/store";
import { Spinner } from "@/shared/ui/spinner";
import { Header, CreateInput, FolderView, RootView } from "@/widgets/documents";
import { Button } from "@/shared/ui/button";
import { Plus, Folder } from "lucide-react";
import { DocumentsPageSkeleton } from "@/shared/ui/skeleton";
const DocumentsPage = () => {
const { user } = useAuth();
const searchParams = useSearchParams();
const currentFolderId = searchParams?.get('folder');
// Use management stores for operations
const documentManagement = useDocumentManagementStore();
const folderManagement = useFolderManagementStore();
// Use entity stores for state
const documentStore = useDocumentStore();
const folderStore = useFolderStore();
const documents = documentStore.documents;
const folders = folderStore.folders;
const isLoading = documentStore.isLoading || folderStore.isLoading;
// Destructure specific functions
const { fetchDocuments, createDocument } = documentManagement;
const { fetchFolders, createFolder } = folderManagement;
const [isCreatingFolder, setIsCreatingFolder] = useState(false);
const [newFolderName, setNewFolderName] = useState('');
const [refreshKey, setRefreshKey] = useState(0);
// Fetch initial data
useEffect(() => {
fetchDocuments();
fetchFolders();
}, []); // eslint-disable-line react-hooks/exhaustive-deps
const handleCreateFolder = async () => {
if (!newFolderName.trim()) return;
const newFolder = await createFolder(newFolderName, currentFolderId);
if (newFolder) {
setNewFolderName('');
setIsCreatingFolder(false);
// Refresh data to show the new folder
fetchFolders();
if (currentFolderId) {
setRefreshKey(prev => prev + 1);
}
}
};
const handleCreateDocumentFromHeader = async () => {
await createDocument(currentFolderId);
if (currentFolderId) {
setRefreshKey(prev => prev + 1);
}
};
const handleNavigateToFolder = (folderId: string) => {
window.history.pushState({}, '', `/documents?folder=${folderId}`);
window.dispatchEvent(new Event('popstate'));
};
const handleGoBack = () => {
window.history.back();
};
const handleFolderDeleted = () => {
// Refresh folders list
fetchFolders();
fetchDocuments();
// Force refresh if we're in a folder view
if (currentFolderId) {
setRefreshKey(prev => prev + 1);
}
};
// Show loading skeleton while data is being fetched
if (isLoading) {
return (
<div className="h-full flex flex-col bg-white dark:bg-[#1F1F1F]">
<Header
userName={user?.name}
documentsCount={documents.length}
foldersCount={folders.length}
isCreating={false}
onCreateFolder={() => setIsCreatingFolder(true)}
onCreateDocument={handleCreateDocumentFromHeader}
/>
<div className="flex-1 overflow-auto bg-gray-50 dark:bg-[#1F1F1F] p-6">
<DocumentsPageSkeleton />
</div>
</div>
);
}
return (
<div className="h-full flex flex-col bg-white dark:bg-[#1F1F1F]">
{/* Header */}
<Header
userName={user?.name}
documentsCount={documents.length}
foldersCount={folders.length}
isCreating={documentManagement.isCreating}
onCreateFolder={() => setIsCreatingFolder(true)}
onCreateDocument={handleCreateDocumentFromHeader}
/>
{/* Create folder input - shown when creating from header */}
{isCreatingFolder && (
<div className="px-6 py-4 border-b border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-secondary">
<CreateInput
value={newFolderName}
onChange={setNewFolderName}
onSubmit={handleCreateFolder}
onCancel={() => {
setIsCreatingFolder(false);
setNewFolderName('');
}}
/>
</div>
)}
{/* Main content - Desktop layout */}
<div className="hidden lg:block flex-1 overflow-hidden bg-gray-50 dark:bg-[#1F1F1F]">
<div className="flex h-full">
<div className="flex-1 overflow-auto">
<div className="p-6">
{currentFolderId ? (
<FolderView
key={`desktop-${currentFolderId}-${refreshKey}`}
folderId={currentFolderId}
onBack={handleGoBack}
onFolderDeleted={handleFolderDeleted}
variant="desktop"
/>
) : (
<RootView
onCreateFolder={() => setIsCreatingFolder(true)}
onNavigateToFolder={handleNavigateToFolder}
onFolderDeleted={handleFolderDeleted}
variant="desktop"
/>
)}
</div>
</div>
</div>
</div>
{/* Mobile layout */}
<div className="lg:hidden flex-1 overflow-auto">
<div className="px-6 py-8 bg-gray-50 dark:bg-[#1F1F1F]">
<div className="max-w-4xl mx-auto space-y-8">
{/* Create folder input */}
{isCreatingFolder && (
<CreateInput
value={newFolderName}
onChange={setNewFolderName}
onSubmit={handleCreateFolder}
onCancel={() => {
setIsCreatingFolder(false);
setNewFolderName('');
}}
/>
)}
{/* Content */}
{currentFolderId ? (
<FolderView
key={`mobile-${currentFolderId}-${refreshKey}`}
folderId={currentFolderId}
onBack={handleGoBack}
onFolderDeleted={handleFolderDeleted}
variant="mobile"
/>
) : (
<RootView
onCreateFolder={() => setIsCreatingFolder(true)}
onNavigateToFolder={handleNavigateToFolder}
onFolderDeleted={handleFolderDeleted}
variant="mobile"
/>
)}
</div>
</div>
</div>
</div>
);
};
export default DocumentsPage;

View File

@@ -0,0 +1,12 @@
'use client'
import { TodoPanel } from '@/widgets/todo'
export default function TodosPage() {
return (
<div className="h-full flex flex-col">
<TodoPanel />
</div>
)
}

BIN
nextjs/app/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

321
nextjs/app/globals.css Normal file
View File

@@ -0,0 +1,321 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
/* Hide scrollbar but keep functionality */
.scrollbar-hide {
-ms-overflow-style: none; /* IE and Edge */
scrollbar-width: none; /* Firefox */
}
.scrollbar-hide::-webkit-scrollbar {
display: none; /* Chrome, Safari and Opera */
}
html,
body,
:root {
height: 100%;
}
@layer base {
:root {
--background: 0 0% 100%;
--foreground: 0 0% 3.9%;
--card: 0 0% 100%;
--card-foreground: 0 0% 3.9%;
--popover: 0 0% 100%;
--popover-foreground: 0 0% 3.9%;
--primary: 0 0% 9%;
--primary-foreground: 0 0% 98%;
--secondary: 0 0% 96.1%;
--secondary-foreground: 0 0% 9%;
--muted: 0 0% 96.1%;
--muted-foreground: 0 0% 45.1%;
--accent: 0 0% 96.1%;
--accent-foreground: 0 0% 9%;
--destructive: 0 84.2% 60.2%;
--destructive-foreground: 0 0% 98%;
--border: 0 0% 89.8%;
--input: 0 0% 89.8%;
--ring: 0 0% 3.9%;
--radius: 0.5rem;
}
.dark {
--background: 0 0% 3.9%;
--foreground: 0 0% 98%;
--card: 0 0% 3.9%;
--card-foreground: 0 0% 98%;
--popover: 0 0% 3.9%;
--popover-foreground: 0 0% 98%;
--primary: 0 0% 98%;
--primary-foreground: 0 0% 9%;
--secondary: 0 0% 14.9%;
--secondary-foreground: 0 0% 98%;
--muted: 0 0% 14.9%;
--muted-foreground: 0 0% 63.9%;
--accent: 0 0% 14.9%;
--accent-foreground: 0 0% 98%;
--destructive: 0 62.8% 30.6%;
--destructive-foreground: 0 0% 98%;
--border: 0 0% 14.9%;
--input: 0 0% 14.9%;
--ring: 0 0% 83.1%;
}
}
@layer base {
* {
@apply border-border;
}
body {
@apply bg-background text-foreground;
}
}
/* Tiptap Editor Styles */
.ProseMirror {
outline: none;
min-height: 200px;
color: #1f2937; /* Default text color for light theme */
}
/* Dark theme text color */
.dark .ProseMirror,
.dark .ProseMirror p,
.dark .ProseMirror div,
.dark .ProseMirror span {
color: #ffffff !important; /* 완전한 하얀색 강제 적용 */
}
.ProseMirror p.is-editor-empty:first-child::before {
color: #adb5bd;
content: attr(data-placeholder);
float: left;
height: 0;
pointer-events: none;
}
/* Dark theme placeholder */
.dark .ProseMirror p.is-editor-empty:first-child::before {
color: #6b7280;
}
.ProseMirror h1 {
font-size: 2em;
font-weight: bold;
margin: 0.67em 0;
color: #111827;
}
.ProseMirror h2 {
font-size: 1.5em;
font-weight: bold;
margin: 0.83em 0;
color: #111827;
}
.ProseMirror h3 {
font-size: 1.17em;
font-weight: bold;
margin: 1em 0;
color: #111827;
}
/* Dark theme headings */
.dark .ProseMirror h1,
.dark .ProseMirror h2,
.dark .ProseMirror h3 {
color: #f9fafb;
}
.ProseMirror ul,
.ProseMirror ol {
padding-left: 1.5em;
color: inherit;
}
.ProseMirror li {
color: inherit;
}
.ProseMirror blockquote {
border-left: 3px solid #e2e8f0;
padding-left: 1rem;
margin: 1rem 0;
font-style: italic;
color: #6b7280;
}
/* Dark theme blockquote */
.dark .ProseMirror blockquote {
color: #ffffff; /* 완전한 하얀색 */
}
.ProseMirror code {
background-color: #f1f5f9;
border-radius: 0.25rem;
padding: 0.125rem 0.25rem;
font-family: 'Courier New', monospace;
color: #374151;
}
/* Dark theme inline code */
.dark .ProseMirror code {
background-color: #374151;
color: #f3f4f6;
}
.ProseMirror pre {
background-color: #1e293b;
color: #e2e8f0;
border-radius: 0.5rem;
padding: 1rem;
overflow-x: auto;
margin: 1rem 0;
}
.ProseMirror pre code {
background: none;
padding: 0;
color: inherit;
}
.ProseMirror table {
border-collapse: collapse;
margin: 1rem 0;
width: 100%;
}
.ProseMirror th,
.ProseMirror td {
border: 1px solid #e2e8f0;
padding: 0.5rem;
text-align: left;
}
.ProseMirror th {
background-color: #f8fafc;
font-weight: bold;
}
.ProseMirror img {
max-width: 100%;
height: auto;
border-radius: 0.5rem;
}
.ProseMirror a {
color: #3b82f6;
text-decoration: underline;
}
/* Dark theme links */
.dark .ProseMirror a {
color: #60a5fa;
}
.ProseMirror mark {
background-color: #fef08a;
padding: 0.125rem 0.25rem;
border-radius: 0.25rem;
}
/* Dark theme highlight */
.dark .ProseMirror mark {
background-color: #fbbf24;
color: #111827;
}
.ProseMirror ul[data-type="taskList"] {
list-style: none;
padding: 0;
}
.ProseMirror ul[data-type="taskList"] li {
display: flex;
align-items: flex-start;
}
.ProseMirror ul[data-type="taskList"] li > label {
flex: 0 0 auto;
margin-right: 0.5rem;
user-select: none;
}
.ProseMirror ul[data-type="taskList"] li > div {
flex: 1 1 auto;
}
.ProseMirror ul[data-type="taskList"] input[type="checkbox"] {
cursor: pointer;
}
/* Dark mode styles */
.dark .ProseMirror blockquote {
border-left-color: #475569;
}
.dark .ProseMirror code {
background-color: #334155;
}
.dark .ProseMirror th {
background-color: #1e293b;
}
.dark .ProseMirror th,
.dark .ProseMirror td {
border-color: #475569;
}
/* Line clamp utilities */
.line-clamp-2 {
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.line-clamp-3 {
display: -webkit-box;
-webkit-line-clamp: 3;
-webkit-box-orient: vertical;
overflow: hidden;
}
/* Code block styles */
pre {
display: block;
overflow-x: auto;
padding: 1rem;
background: #1e1e1e;
color: #d4d4d4;
border-radius: 0.5rem;
font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;
font-size: 0.875rem;
line-height: 1.5;
}
/* Light mode code block */
.light pre {
background: #f8f8f8;
color: #333;
}

View File

@@ -0,0 +1,12 @@
import { Navbar } from "@/widgets/landing";
const MarketingLayout = ({ children }: { children: React.ReactNode }) => {
return (
<div className="h-full bg-white dark:bg-[#1F1F1F] space-y-20">
<Navbar />
<main className="h-full">{children}</main>
</div>
);
};
export default MarketingLayout;

17
nextjs/app/home/page.tsx Normal file
View File

@@ -0,0 +1,17 @@
import { Heading } from "@/widgets/landing";
import { Heroes } from "@/widgets/landing";
import { Footer } from "@/widgets/landing";
const MarketingPage = () => {
return (
<div className="mt-40 dark:bg-[#1F1F1F] bg-white min-h-full flex flex-col">
<div className="flex flex-col items-center justify-center md:justify-start text-center gap-y-8 flex-1 px-6 pb-10">
<Heading />
<Heroes />
</div>
<Footer />
</div>
);
};
export default MarketingPage;

View File

@@ -0,0 +1,161 @@
"use client";
import { useState, useEffect } from 'react';
import { useParams } from 'next/navigation';
import { DocumentSidebar } from '@/widgets/editor/sidebar/document-sidebar';
import { RichTextEditor } from '@/widgets/editor/editor/core/rich-text-editor';
import { Button } from '@/shared/ui/button';
import { ArrowLeft, Copy, Check, Share2, User, Calendar } from 'lucide-react';
import Link from 'next/link';
import type { Document } from '@/shared/types/document';
import { SharedDocumentSkeleton } from '@/shared/ui/skeleton';
interface SharedDocument extends Document {
user: {
name: string;
email: string;
};
}
export default function ShareDocumentPage() {
const params = useParams();
const documentId = params.id as string;
const [document, setDocument] = useState<SharedDocument | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
const fetchDocument = async () => {
try {
const response = await fetch(`/api/documents/${documentId}/public`);
if (!response.ok) {
throw new Error('Document not found or not published');
}
const data = await response.json();
setDocument(data);
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to load document');
} finally {
setLoading(false);
}
};
if (documentId) {
fetchDocument();
}
}, [documentId]);
const getWordCount = (content: any): number => {
if (!content) return 0;
const extractText = (node: any): string => {
if (typeof node === 'string') return node;
if (node.text) return node.text;
if (node.content) {
return node.content.map(extractText).join(' ');
}
return '';
};
const text = extractText(content);
return text.trim().split(/\s+/).filter(word => word.length > 0).length;
};
if (loading) {
return <SharedDocumentSkeleton />;
}
if (error || !document) {
return (
<div className="min-h-screen bg-gray-50 dark:bg-[#1F1F1F] flex items-center justify-center">
<div className="text-center max-w-md mx-auto p-6">
<div className="text-red-500 dark:text-red-400 text-6xl mb-4">📄</div>
<h1 className="text-2xl font-bold text-gray-900 dark:text-white mb-2">Document Not Found</h1>
<p className="text-gray-600 dark:text-gray-400 mb-6">
{error || 'This document may not exist or may not be publicly shared.'}
</p>
<Link href="/">
<Button className="bg-blue-600 dark:bg-blue-700 text-white hover:bg-blue-700 dark:hover:bg-blue-800">
<ArrowLeft className="h-4 w-4 mr-2" />
Go Home
</Button>
</Link>
</div>
</div>
);
}
const wordCount = getWordCount(document.content);
return (
<div className="min-h-screen bg-gray-50 dark:bg-[#1F1F1F]">
{/* Main Content */}
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
<div className="grid grid-cols-1 lg:grid-cols-4 gap-8">
{/* Document Content */}
<div className="lg:col-span-3">
<div className="bg-white dark:bg-secondary rounded-xl shadow-sm border border-gray-200 dark:border-gray-700 p-8">
<div className="prose prose-lg max-w-none dark:prose-invert">
<RichTextEditor
content={document.content}
editable={false}
readOnly={true}
placeholder=""
/>
</div>
</div>
</div>
{/* Sidebar */}
<div className="lg:col-span-1">
<div className="sticky top-24">
<DocumentSidebar
content={document.content}
title={document.title}
lastSaved={new Date(document.updatedAt)}
wordCount={wordCount}
documentId={document.id}
/>
{/* Document Info */}
<div className="mt-6 bg-white dark:bg-secondary rounded-lg border border-gray-200 dark:border-gray-700 p-4">
<h3 className="font-semibold text-gray-900 dark:text-white mb-3 flex items-center">
<User className="h-4 w-4 mr-2" />
Document Info
</h3>
<div className="space-y-2 text-sm text-gray-600 dark:text-gray-400">
<div className="flex items-center">
<User className="h-4 w-4 mr-2" />
<span>Author: {document.user.name}</span>
</div>
<div className="flex items-center">
<Calendar className="h-4 w-4 mr-2" />
<span>Created: {new Date(document.createdAt).toLocaleDateString()}</span>
</div>
<div className="flex items-center">
<Calendar className="h-4 w-4 mr-2" />
<span>Updated: {new Date(document.updatedAt).toLocaleDateString()}</span>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
{/* Footer */}
<footer className="bg-white dark:bg-secondary border-t border-gray-200 dark:border-gray-700 mt-16">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
<div className="text-center text-gray-500 dark:text-gray-400 text-sm">
<p>This document was shared from Jotion</p>
<p className="mt-1">
Last updated: {new Date(document.updatedAt).toLocaleDateString()}
</p>
</div>
</div>
</footer>
</div>
);
}

View File

@@ -0,0 +1,116 @@
"use client"
import { useState } from "react"
import { useRouter } from "next/navigation"
import { Button } from "@/shared/ui/button"
import { Logo } from "@/widgets/landing"
import Link from "next/link"
import { useAuth } from "@/src/app/providers/auth-provider"
export default function LoginPage() {
const [email, setEmail] = useState("")
const [password, setPassword] = useState("")
const [isLoading, setIsLoading] = useState(false)
const [error, setError] = useState("")
const router = useRouter()
const { login } = useAuth()
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
setIsLoading(true)
setError("")
try {
const response = await fetch("/api/auth/login", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ email, password }),
})
const data = await response.json()
if (response.ok) {
// Update auth context with user data
login(data.token, data.user)
// Redirect to documents page
router.push("/documents")
} else {
setError(data.error || "Login failed")
}
} catch (error) {
setError("An error occurred. Please try again.")
} finally {
setIsLoading(false)
}
}
return (
<div className="min-h-screen flex items-center justify-center bg-background">
<div className="max-w-md w-full space-y-8 p-8">
<div className="text-center">
<Logo />
<h2 className="mt-6 text-3xl font-bold">Sign in to your account</h2>
<p className="mt-2 text-sm text-muted-foreground">
Or{" "}
<Link href="/signUp" className="font-medium text-primary hover:underline">
create a new account
</Link>
</p>
</div>
<form className="mt-8 space-y-6" onSubmit={handleSubmit}>
{error && (
<div className="bg-destructive/15 text-destructive text-sm p-3 rounded-md">
{error}
</div>
)}
<div className="space-y-4">
<div>
<label htmlFor="email" className="block text-sm font-medium">
Email address
</label>
<input
id="email"
name="email"
type="email"
required
value={email}
onChange={(e) => setEmail(e.target.value)}
className="mt-1 block w-full px-3 py-2 border border-input rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent"
placeholder="Enter your email"
/>
</div>
<div>
<label htmlFor="password" className="block text-sm font-medium">
Password
</label>
<input
id="password"
name="password"
type="password"
required
value={password}
onChange={(e) => setPassword(e.target.value)}
className="mt-1 block w-full px-3 py-2 border border-input rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent"
placeholder="Enter your password"
/>
</div>
</div>
<Button
type="submit"
className="w-full"
disabled={isLoading}
>
{isLoading ? "Signing in..." : "Sign in"}
</Button>
</form>
</div>
</div>
)
}

View File

@@ -0,0 +1,167 @@
"use client"
import { useState } from "react"
import { useRouter } from "next/navigation"
import { Button } from "@/shared/ui/button"
import { Logo } from "@/widgets/landing"
import Link from "next/link"
import { useAuth } from "@/src/app/providers/auth-provider"
export default function SignupPage() {
const [name, setName] = useState("")
const [email, setEmail] = useState("")
const [password, setPassword] = useState("")
const [confirmPassword, setConfirmPassword] = useState("")
const [isLoading, setIsLoading] = useState(false)
const [error, setError] = useState("")
const router = useRouter()
const { login } = useAuth()
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
setIsLoading(true)
setError("")
if (password !== confirmPassword) {
setError("Passwords do not match")
setIsLoading(false)
return
}
try {
const response = await fetch("/api/auth/register", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ name, email, password }),
})
const data = await response.json()
if (response.ok) {
// Auto login after successful registration
const loginResponse = await fetch("/api/auth/login", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ email, password }),
})
const loginData = await loginResponse.json()
if (loginResponse.ok) {
login(loginData.token, loginData.user)
router.push("/documents")
} else {
router.push("/signIn")
}
} else {
setError(data.error || "Registration failed")
}
} catch (error) {
setError("An error occurred. Please try again.")
} finally {
setIsLoading(false)
}
}
return (
<div className="min-h-screen flex items-center justify-center bg-background">
<div className="max-w-md w-full space-y-8 p-8">
<div className="text-center">
<Logo />
<h2 className="mt-6 text-3xl font-bold">Create your account</h2>
<p className="mt-2 text-sm text-muted-foreground">
Or{" "}
<Link href="/signIn" className="font-medium text-primary hover:underline">
sign in to your existing account
</Link>
</p>
</div>
<form className="mt-8 space-y-6" onSubmit={handleSubmit}>
{error && (
<div className="bg-destructive/15 text-destructive text-sm p-3 rounded-md">
{error}
</div>
)}
<div className="space-y-4">
<div>
<label htmlFor="name" className="block text-sm font-medium">
Full name
</label>
<input
id="name"
name="name"
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
className="mt-1 block w-full px-3 py-2 border border-input rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent"
placeholder="Enter your full name"
/>
</div>
<div>
<label htmlFor="email" className="block text-sm font-medium">
Email address
</label>
<input
id="email"
name="email"
type="email"
required
value={email}
onChange={(e) => setEmail(e.target.value)}
className="mt-1 block w-full px-3 py-2 border border-input rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent"
placeholder="Enter your email"
/>
</div>
<div>
<label htmlFor="password" className="block text-sm font-medium">
Password
</label>
<input
id="password"
name="password"
type="password"
required
value={password}
onChange={(e) => setPassword(e.target.value)}
className="mt-1 block w-full px-3 py-2 border border-input rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent"
placeholder="Enter your password"
/>
</div>
<div>
<label htmlFor="confirmPassword" className="block text-sm font-medium">
Confirm password
</label>
<input
id="confirmPassword"
name="confirmPassword"
type="password"
required
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
className="mt-1 block w-full px-3 py-2 border border-input rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent"
placeholder="Confirm your password"
/>
</div>
</div>
<Button
type="submit"
className="w-full"
disabled={isLoading}
>
{isLoading ? "Creating account..." : "Create account"}
</Button>
</form>
</div>
</div>
)
}

62
nextjs/app/layout.tsx Normal file
View File

@@ -0,0 +1,62 @@
import type { Metadata } from "next";
import { Inter } from "next/font/google";
import "./globals.css";
import { ThemeProvider } from "@/src/app/providers/theme-provider";
import { AuthProvider } from "@/src/app/providers/auth-provider";
import NextTopLoader from 'nextjs-toploader';
const inter = Inter({ subsets: ["latin"] });
export const metadata: Metadata = {
title: "Jotion",
description: "Generated by Jotion",
icons: {
icon: [
{
media: "(prefers-color-scheme: light)",
url: "/next.svg",
href: "/next.svg",
},
{
media: "(prefers-color-scheme: dark)",
url: "/next.svg",
href: "/next.svg",
}
]
}
};
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<html lang="en" suppressHydrationWarning>
<body className={`${inter.className} bg-white dark:bg-[#1F1F1F]`}>
<NextTopLoader
color="#3b82f6"
initialPosition={0.08}
crawlSpeed={200}
height={3}
crawl={true}
showSpinner={false}
easing="ease"
speed={200}
shadow="0 0 10px #3b82f6,0 0 5px #3b82f6"
/>
<ThemeProvider
attribute="class"
defaultTheme="system"
enableSystem
disableTransitionOnChange
storageKey="notion-theme"
>
<AuthProvider>
{children}
</AuthProvider>
</ThemeProvider>
</body>
</html>
);
}

17
nextjs/components.json Normal file
View File

@@ -0,0 +1,17 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "default",
"rsc": true,
"tsx": true,
"tailwind": {
"config": "tailwind.config.ts",
"css": "app/globals.css",
"baseColor": "neutral",
"cssVariables": true,
"prefix": ""
},
"aliases": {
"components": "@/components",
"utils": "@/lib/utils"
}
}

71
nextjs/middleware.ts Normal file
View File

@@ -0,0 +1,71 @@
import { NextRequest, NextResponse } from 'next/server'
import { verifyToken } from './src/shared/lib/auth'
export function middleware(request: NextRequest) {
const { pathname } = request.nextUrl
// Public paths that don't require authentication
const publicPaths = [
'/',
'/home',
'/home/signIn',
'/home/signUp',
'/home/share',
'/api/auth/login',
'/api/auth/register',
'/api/health',
'/api/metadata',
]
// Check if the current path is public
const isPublicPath = publicPaths.some(path => pathname.startsWith(path))
// Allow public paths
if (isPublicPath) {
return NextResponse.next()
}
// Check for authentication on protected paths
if (pathname.startsWith('/documents')) {
// Get token from cookie
const token = request.cookies.get('auth-token')?.value
if (!token) {
// Redirect to home page if no token
const url = new URL('/home', request.url)
return NextResponse.redirect(url)
}
// Verify token
const payload = verifyToken(token)
if (!payload) {
// Invalid token, clear cookie and redirect
const url = new URL('/home', request.url)
const response = NextResponse.redirect(url)
response.cookies.delete('auth-token')
return response
}
// Token is valid, continue to the requested page
return NextResponse.next()
}
// Allow all other paths by default
return NextResponse.next()
}
// Configure which paths this middleware should run on
export const config = {
matcher: [
/*
* Match all request paths except for the ones starting with:
* - _next/static (static files)
* - _next/image (image optimization files)
* - favicon.ico (favicon file)
* - public folder
*/
'/((?!_next/static|_next/image|favicon.ico|.*\\.(?:svg|png|jpg|jpeg|gif|webp)$).*)',
],
}

48
nextjs/next.config.mjs Normal file
View File

@@ -0,0 +1,48 @@
/** @type {import('next').NextConfig} */
const nextConfig = {
// Enable standalone output for Docker
output: 'standalone',
// Optimize for Docker
outputFileTracingRoot: '/app',
// Image optimization
images: {
remotePatterns: [
{
protocol: 'https',
hostname: '**',
},
],
},
// API configuration
async headers() {
return [
{
source: '/api/:path*',
headers: [
{
key: 'Cache-Control',
value: 'no-store, max-age=0',
},
],
},
];
},
// Webpack configuration for Docker
webpack: (config, { isServer }) => {
if (!isServer) {
config.resolve.fallback = {
...config.resolve.fallback,
fs: false,
net: false,
tls: false,
};
}
return config;
},
};
export default nextConfig;

6346
nextjs/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

66
nextjs/package.json Normal file
View File

@@ -0,0 +1,66 @@
{
"name": "notion-clone",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "next lint"
},
"dependencies": {
"@aws-sdk/client-s3": "^3.918.0",
"@aws-sdk/s3-request-presigner": "^3.918.0",
"@monaco-editor/react": "^4.6.0",
"@prisma/client": "^6.16.3",
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-dropdown-menu": "^2.0.6",
"@radix-ui/react-label": "^2.1.7",
"@radix-ui/react-slot": "^1.0.2",
"@supabase/supabase-js": "^2.76.1",
"@tiptap/core": "^3.6.3",
"@tiptap/extension-highlight": "^3.6.3",
"@tiptap/extension-image": "^3.6.3",
"@tiptap/extension-link": "^3.6.3",
"@tiptap/extension-placeholder": "^3.6.3",
"@tiptap/extension-strike": "^3.6.3",
"@tiptap/extension-table": "^3.6.3",
"@tiptap/extension-table-cell": "^3.6.3",
"@tiptap/extension-table-header": "^3.6.3",
"@tiptap/extension-table-row": "^3.6.3",
"@tiptap/extension-task-item": "^3.6.3",
"@tiptap/extension-task-list": "^3.6.3",
"@tiptap/extension-text-align": "^3.6.3",
"@tiptap/extension-underline": "^3.6.3",
"@tiptap/react": "^3.6.3",
"@tiptap/starter-kit": "^3.6.3",
"autoprefixer": "^10.4.21",
"bcryptjs": "^3.0.2",
"class-variance-authority": "^0.7.0",
"clsx": "^2.1.0",
"jsonwebtoken": "^9.0.2",
"lucide-react": "^0.544.0",
"monaco-editor": "^0.47.0",
"next": "^15.5.4",
"next-themes": "^0.4.6",
"nextjs-toploader": "^3.9.17",
"node-html-parser": "^7.0.1",
"prisma": "^6.16.3",
"react": "^19.2.0",
"react-dom": "^19.2.0",
"tailwind-merge": "^2.3.0",
"tailwindcss-animate": "^1.0.7",
"usehooks-ts": "^3.1.0",
"zod": "^4.1.12",
"zustand": "^5.0.8"
},
"devDependencies": {
"@types/bcryptjs": "^3.0.0",
"@types/jsonwebtoken": "^9.0.10",
"@types/node": "20.12.11",
"@types/react": "18.3.2",
"@types/react-dom": "^18",
"tailwindcss": "^3.4.1",
"typescript": "^5"
}
}

View File

@@ -0,0 +1,9 @@
/** @type {import('postcss-load-config').Config} */
const config = {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};
export default config;

131
nextjs/prisma/schema.prisma Normal file
View File

@@ -0,0 +1,131 @@
// This is your Prisma schema file,
// learn more about it in the docs: https://pris.ly/d/prisma-schema
generator client {
provider = "prisma-client-js"
output = "../node_modules/.prisma/client"
}
datasource db {
provider = "mysql"
url = env("DATABASE_URL")
}
// User model for authentication
model User {
id String @id @default(uuid())
email String @unique
name String?
image String?
password String // Hashed password
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
// Relations
documents Document[]
folders Folder[]
templates Template[]
@@map("users")
}
// Folder model for organizing documents
model Folder {
id String @id @default(uuid())
name String
icon String? // Folder icon
color String? // Folder color
isArchived Boolean @default(false)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
// Hierarchy
parentId String?
parent Folder? @relation("FolderHierarchy", fields: [parentId], references: [id])
children Folder[] @relation("FolderHierarchy")
// Ownership
userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
// Relations
documents Document[]
// Indexes
@@index([userId])
@@index([parentId])
@@index([isArchived])
@@map("folders")
}
// Document model for Notion-like documents
model Document {
id String @id @default(uuid())
title String
content Json? // JSON content for rich text
icon String? // Document icon
cover String? // Cover image URL
isPublished Boolean @default(false)
isArchived Boolean @default(false)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
// Document type
type DocumentType @default(PAGE) // PAGE, CODE_FILE
// Code file specific fields
filePath String? // Path within the repository
fileContent String? // Raw file content
language String? // Programming language
fileSize Int? // File size in bytes
// Hierarchy
parentId String?
parent Document? @relation("DocumentHierarchy", fields: [parentId], references: [id])
children Document[] @relation("DocumentHierarchy")
// Folder relationship
folderId String?
folder Folder? @relation(fields: [folderId], references: [id], onDelete: SetNull)
// Ownership
userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
// Indexes
@@index([userId])
@@index([parentId])
@@index([folderId])
@@index([isArchived])
@@index([type])
@@map("documents")
}
// Template model for document templates
model Template {
id String @id @default(uuid())
name String
description String?
category String @default("General")
title String
content Json // JSON content for rich text
isPublic Boolean @default(false)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
// Ownership
userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
// Indexes
@@index([userId])
@@index([category])
@@index([isPublic])
@@map("templates")
}
// Document type enum
enum DocumentType {
PAGE
CODE_FILE
}

BIN
nextjs/public/documents.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 54 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 139 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

View File

@@ -0,0 +1,3 @@
// App configuration exports
export * from './metadata'

View File

@@ -0,0 +1,21 @@
import type { Metadata } from "next";
export const metadata: Metadata = {
title: "Jotion",
description: "Generated by Jotion",
icons: {
icon: [
{
media: "(prefers-color-scheme: light)",
url: "/next.svg",
href: "/next.svg",
},
{
media: "(prefers-color-scheme: dark)",
url: "/next.svg",
href: "/next.svg",
}
]
}
};

BIN
nextjs/src/app/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

321
nextjs/src/app/globals.css Normal file
View File

@@ -0,0 +1,321 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
/* Hide scrollbar but keep functionality */
.scrollbar-hide {
-ms-overflow-style: none; /* IE and Edge */
scrollbar-width: none; /* Firefox */
}
.scrollbar-hide::-webkit-scrollbar {
display: none; /* Chrome, Safari and Opera */
}
html,
body,
:root {
height: 100%;
}
@layer base {
:root {
--background: 0 0% 100%;
--foreground: 0 0% 3.9%;
--card: 0 0% 100%;
--card-foreground: 0 0% 3.9%;
--popover: 0 0% 100%;
--popover-foreground: 0 0% 3.9%;
--primary: 0 0% 9%;
--primary-foreground: 0 0% 98%;
--secondary: 0 0% 96.1%;
--secondary-foreground: 0 0% 9%;
--muted: 0 0% 96.1%;
--muted-foreground: 0 0% 45.1%;
--accent: 0 0% 96.1%;
--accent-foreground: 0 0% 9%;
--destructive: 0 84.2% 60.2%;
--destructive-foreground: 0 0% 98%;
--border: 0 0% 89.8%;
--input: 0 0% 89.8%;
--ring: 0 0% 3.9%;
--radius: 0.5rem;
}
.dark {
--background: 0 0% 3.9%;
--foreground: 0 0% 98%;
--card: 0 0% 3.9%;
--card-foreground: 0 0% 98%;
--popover: 0 0% 3.9%;
--popover-foreground: 0 0% 98%;
--primary: 0 0% 98%;
--primary-foreground: 0 0% 9%;
--secondary: 0 0% 14.9%;
--secondary-foreground: 0 0% 98%;
--muted: 0 0% 14.9%;
--muted-foreground: 0 0% 63.9%;
--accent: 0 0% 14.9%;
--accent-foreground: 0 0% 98%;
--destructive: 0 62.8% 30.6%;
--destructive-foreground: 0 0% 98%;
--border: 0 0% 14.9%;
--input: 0 0% 14.9%;
--ring: 0 0% 83.1%;
}
}
@layer base {
* {
@apply border-border;
}
body {
@apply bg-background text-foreground;
}
}
/* Tiptap Editor Styles */
.ProseMirror {
outline: none;
min-height: 200px;
color: #1f2937; /* Default text color for light theme */
}
/* Dark theme text color */
.dark .ProseMirror,
.dark .ProseMirror p,
.dark .ProseMirror div,
.dark .ProseMirror span {
color: #ffffff !important; /* 완전한 하얀색 강제 적용 */
}
.ProseMirror p.is-editor-empty:first-child::before {
color: #adb5bd;
content: attr(data-placeholder);
float: left;
height: 0;
pointer-events: none;
}
/* Dark theme placeholder */
.dark .ProseMirror p.is-editor-empty:first-child::before {
color: #6b7280;
}
.ProseMirror h1 {
font-size: 2em;
font-weight: bold;
margin: 0.67em 0;
color: #111827;
}
.ProseMirror h2 {
font-size: 1.5em;
font-weight: bold;
margin: 0.83em 0;
color: #111827;
}
.ProseMirror h3 {
font-size: 1.17em;
font-weight: bold;
margin: 1em 0;
color: #111827;
}
/* Dark theme headings */
.dark .ProseMirror h1,
.dark .ProseMirror h2,
.dark .ProseMirror h3 {
color: #f9fafb;
}
.ProseMirror ul,
.ProseMirror ol {
padding-left: 1.5em;
color: inherit;
}
.ProseMirror li {
color: inherit;
}
.ProseMirror blockquote {
border-left: 3px solid #e2e8f0;
padding-left: 1rem;
margin: 1rem 0;
font-style: italic;
color: #6b7280;
}
/* Dark theme blockquote */
.dark .ProseMirror blockquote {
color: #ffffff; /* 완전한 하얀색 */
}
.ProseMirror code {
background-color: #f1f5f9;
border-radius: 0.25rem;
padding: 0.125rem 0.25rem;
font-family: 'Courier New', monospace;
color: #374151;
}
/* Dark theme inline code */
.dark .ProseMirror code {
background-color: #374151;
color: #f3f4f6;
}
.ProseMirror pre {
background-color: #1e293b;
color: #e2e8f0;
border-radius: 0.5rem;
padding: 1rem;
overflow-x: auto;
margin: 1rem 0;
}
.ProseMirror pre code {
background: none;
padding: 0;
color: inherit;
}
.ProseMirror table {
border-collapse: collapse;
margin: 1rem 0;
width: 100%;
}
.ProseMirror th,
.ProseMirror td {
border: 1px solid #e2e8f0;
padding: 0.5rem;
text-align: left;
}
.ProseMirror th {
background-color: #f8fafc;
font-weight: bold;
}
.ProseMirror img {
max-width: 100%;
height: auto;
border-radius: 0.5rem;
}
.ProseMirror a {
color: #3b82f6;
text-decoration: underline;
}
/* Dark theme links */
.dark .ProseMirror a {
color: #60a5fa;
}
.ProseMirror mark {
background-color: #fef08a;
padding: 0.125rem 0.25rem;
border-radius: 0.25rem;
}
/* Dark theme highlight */
.dark .ProseMirror mark {
background-color: #fbbf24;
color: #111827;
}
.ProseMirror ul[data-type="taskList"] {
list-style: none;
padding: 0;
}
.ProseMirror ul[data-type="taskList"] li {
display: flex;
align-items: flex-start;
}
.ProseMirror ul[data-type="taskList"] li > label {
flex: 0 0 auto;
margin-right: 0.5rem;
user-select: none;
}
.ProseMirror ul[data-type="taskList"] li > div {
flex: 1 1 auto;
}
.ProseMirror ul[data-type="taskList"] input[type="checkbox"] {
cursor: pointer;
}
/* Dark mode styles */
.dark .ProseMirror blockquote {
border-left-color: #475569;
}
.dark .ProseMirror code {
background-color: #334155;
}
.dark .ProseMirror th {
background-color: #1e293b;
}
.dark .ProseMirror th,
.dark .ProseMirror td {
border-color: #475569;
}
/* Line clamp utilities */
.line-clamp-2 {
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.line-clamp-3 {
display: -webkit-box;
-webkit-line-clamp: 3;
-webkit-box-orient: vertical;
overflow: hidden;
}
/* Code block styles */
pre {
display: block;
overflow-x: auto;
padding: 1rem;
background: #1e1e1e;
color: #d4d4d4;
border-radius: 0.5rem;
font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;
font-size: 0.875rem;
line-height: 1.5;
}
/* Light mode code block */
.light pre {
background: #f8f8f8;
color: #333;
}

View File

@@ -0,0 +1,72 @@
"use client"
import { createContext, useContext, useEffect, useState } from "react"
import { useRouter } from "next/navigation"
import type { User, AuthContextType } from "@/shared/types"
const AuthContext = createContext<AuthContextType | undefined>(undefined)
export function AuthProvider({ children }: { children: React.ReactNode }) {
const [user, setUser] = useState<User | null>(null)
const [isLoading, setIsLoading] = useState(true)
const router = useRouter()
useEffect(() => {
// Check for existing session via API
checkAuth()
}, [])
const checkAuth = async () => {
try {
const response = await fetch('/api/auth/me', {
credentials: 'include',
})
if (response.ok) {
const userData = await response.json()
setUser(userData)
} else {
setUser(null)
}
} catch (error) {
console.error("Error checking auth:", error)
setUser(null)
} finally {
setIsLoading(false)
}
}
const login = async (token: string, userData: User) => {
// Token is already set in cookie by login API
setUser(userData)
}
const logout = async () => {
try {
// Clear auth cookie by calling logout API
await fetch('/api/auth/logout', {
method: 'POST',
credentials: 'include',
})
} catch (error) {
console.error("Error during logout:", error)
}
setUser(null)
router.push("/home")
}
return (
<AuthContext.Provider value={{ user, isLoading, login, logout }}>
{children}
</AuthContext.Provider>
)
}
export function useAuth() {
const context = useContext(AuthContext)
if (context === undefined) {
throw new Error("useAuth must be used within an AuthProvider")
}
return context
}

View File

@@ -0,0 +1,10 @@
"use client"
import * as React from "react"
import { ThemeProvider as NextThemesProvider } from "next-themes"
import { type ThemeProviderProps } from "next-themes"
export function ThemeProvider({ children, ...props }: ThemeProviderProps) {
return <NextThemesProvider {...props}>{children}</NextThemesProvider>
}

View File

@@ -0,0 +1,67 @@
import type { Document, DocumentWithRelations } from '../model/types';
import type { DocumentInput } from '../model/validation';
import { apiGet, apiPost, apiPut, apiDelete } from '@/shared/api';
// Document API functions
export const documentApi = {
// Get all documents
getDocuments: async (): Promise<DocumentWithRelations[]> => {
try {
return await apiGet<DocumentWithRelations[]>('/api/documents');
} catch (error) {
console.error('Failed to fetch documents:', error);
throw error;
}
},
// Get a specific document
getDocument: async (id: string): Promise<DocumentWithRelations> => {
try {
return await apiGet<DocumentWithRelations>(`/api/documents/${id}`);
} catch (error) {
console.error('Failed to fetch document:', error);
throw error;
}
},
// Create a new document
createDocument: async (newDocument: DocumentInput): Promise<Document> => {
try {
return await apiPost<Document>('/api/documents', newDocument);
} catch (error) {
console.error('Failed to create document:', error);
throw error;
}
},
// Update a document
updateDocument: async (id: string, updateData: Partial<Document>): Promise<Document> => {
try {
return await apiPut<Document>(`/api/documents/${id}`, updateData);
} catch (error) {
console.error('Failed to update document:', error);
throw error;
}
},
// Delete a document
deleteDocument: async (id: string): Promise<void> => {
try {
await apiDelete<void>(`/api/documents/${id}`);
} catch (error) {
console.error('Failed to delete document:', error);
throw error;
}
},
// Get public document
getPublicDocument: async (id: string): Promise<DocumentWithRelations> => {
try {
return await apiGet<DocumentWithRelations>(`/api/documents/${id}/public`);
} catch (error) {
console.error('Failed to fetch public document:', error);
throw error;
}
},
};

View File

@@ -0,0 +1,4 @@
export * from './api';
export * from './model';
export * from './ui';

View File

@@ -0,0 +1,4 @@
export * from './types';
export * from './store';
export * from './validation';

View File

@@ -0,0 +1,36 @@
import { create } from 'zustand';
import { DocumentWithRelations, DocumentState, DocumentActions } from './types';
// Document state management store
export const useDocumentStore = create<DocumentState & DocumentActions>((set) => ({
documents: [],
currentDocument: null,
isLoading: true,
readDocuments: (documents: DocumentWithRelations[]) => set({ documents }),
setCurrentDocument: (document: DocumentWithRelations | null) => set({ currentDocument: document }),
createDocument: (newDocument: DocumentWithRelations) => set((state) => ({
documents: [newDocument, ...state.documents]
})),
updateDocument: (updatedDocument: DocumentWithRelations) => set((state) => ({
documents: state.documents.map(doc =>
doc.id === updatedDocument.id ? updatedDocument : doc
),
currentDocument: state.currentDocument?.id === updatedDocument.id
? updatedDocument
: state.currentDocument
})),
deleteDocument: (documentId: string) => set((state) => ({
documents: state.documents.filter(doc => doc.id !== documentId),
currentDocument: state.currentDocument?.id === documentId
? null
: state.currentDocument
})),
setLoading: (isLoading: boolean) => set({ isLoading }),
}));

View File

@@ -0,0 +1,70 @@
// Document entity types
export type DocumentType = 'PAGE' | 'CODE_FILE';
export interface Document {
id: string;
title: string;
content?: any; // JSON content for rich text
icon?: string;
cover?: string;
isPublished: boolean;
isArchived: boolean;
createdAt: Date | string;
updatedAt: Date | string;
type: DocumentType;
filePath?: string;
fileContent?: string;
language?: string;
fileSize?: number;
parentId?: string;
folderId?: string;
userId: string;
}
export interface DocumentWithRelations extends Document {
parent?: Document;
children?: Document[];
folder?: {
id: string;
name: string;
icon?: string;
};
user?: any;
_count?: {
documents: number;
};
}
export interface DocumentListItem {
id: string;
title: string;
icon?: string;
updatedAt: string;
}
export interface HeadingItem {
id: string;
text: string;
level: number;
element?: HTMLElement;
children?: HeadingItem[];
isExpanded?: boolean;
}
// Document state
export interface DocumentState {
documents: DocumentWithRelations[];
currentDocument: DocumentWithRelations | null;
isLoading: boolean;
}
// Document actions
export interface DocumentActions {
readDocuments: (documents: DocumentWithRelations[]) => void;
setCurrentDocument: (document: DocumentWithRelations | null) => void;
createDocument: (document: DocumentWithRelations) => void;
updateDocument: (updatedDocument: DocumentWithRelations) => void;
deleteDocument: (documentId: string) => void;
setLoading: (isLoading: boolean) => void;
}

View File

@@ -0,0 +1,21 @@
import { z } from 'zod';
// Document validation schemas
export const documentSchema = z.object({
title: z.string().min(1, 'Title is required').max(200, 'Title is too long'),
content: z.any().optional(),
icon: z.string().optional(),
cover: z.string().optional(),
isPublished: z.boolean().default(false),
isArchived: z.boolean().default(false),
type: z.enum(['PAGE', 'CODE_FILE']).default('PAGE'),
filePath: z.string().optional(),
fileContent: z.string().optional(),
language: z.string().optional(),
fileSize: z.number().optional(),
parentId: z.string().optional(),
folderId: z.string().optional(),
});
export type DocumentInput = z.infer<typeof documentSchema>;

View File

@@ -0,0 +1,45 @@
import { DocumentWithRelations } from '../model/types';
import { cn } from '@/shared/lib/utils';
import { FileText, Calendar, Folder } from 'lucide-react';
import Link from 'next/link';
interface DocumentItemProps {
document: DocumentWithRelations;
className?: string;
}
export const DocumentItem = ({ document, className }: DocumentItemProps) => {
const Icon = document.icon ?
<span className="text-2xl">{document.icon}</span> :
<FileText className="w-6 h-6 text-gray-400" />;
return (
<Link
href={`/documents/${document.id}`}
className={cn(
"group flex items-center gap-3 p-3 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors",
className
)}
>
<div className="flex items-center justify-center w-10 h-10 rounded flex-shrink-0">
{Icon}
</div>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium truncate">{document.title}</p>
<div className="flex items-center gap-4 text-xs text-gray-500 mt-1">
{document.folder && (
<div className="flex items-center gap-1">
<Folder className="w-3 h-3" />
<span className="truncate">{document.folder.name}</span>
</div>
)}
<div className="flex items-center gap-1">
<Calendar className="w-3 h-3" />
<span>{new Date(document.updatedAt).toLocaleDateString()}</span>
</div>
</div>
</div>
</Link>
);
};

View File

@@ -0,0 +1,2 @@
export * from './DocumentItem';

View File

@@ -0,0 +1,57 @@
import type { Folder, FolderWithRelations } from '../model/types';
import type { FolderInput } from '../model/validation';
import { apiGet, apiPost, apiPut, apiDelete } from '@/shared/api';
// Folder API functions
export const folderApi = {
// Get all folders
getFolders: async (): Promise<FolderWithRelations[]> => {
try {
return await apiGet<FolderWithRelations[]>('/api/folders');
} catch (error) {
console.error('Failed to fetch folders:', error);
throw error;
}
},
// Get a specific folder
getFolder: async (id: string): Promise<FolderWithRelations> => {
try {
return await apiGet<FolderWithRelations>(`/api/folders/${id}`);
} catch (error) {
console.error('Failed to fetch folder:', error);
throw error;
}
},
// Create a new folder
createFolder: async (newFolder: FolderInput): Promise<Folder> => {
try {
return await apiPost<Folder>('/api/folders', newFolder);
} catch (error) {
console.error('Failed to create folder:', error);
throw error;
}
},
// Update a folder
updateFolder: async (id: string, updateData: Partial<Folder>): Promise<Folder> => {
try {
return await apiPut<Folder>(`/api/folders/${id}`, updateData);
} catch (error) {
console.error('Failed to update folder:', error);
throw error;
}
},
// Delete a folder
deleteFolder: async (id: string): Promise<void> => {
try {
await apiDelete<void>(`/api/folders/${id}`);
} catch (error) {
console.error('Failed to delete folder:', error);
throw error;
}
},
};

View File

@@ -0,0 +1,4 @@
export * from './api';
export * from './model';
export * from './ui';

View File

@@ -0,0 +1,4 @@
export * from './types';
export * from './store';
export * from './validation';

View File

@@ -0,0 +1,36 @@
import { create } from 'zustand';
import { FolderWithRelations, FolderState, FolderActions } from './types';
// Folder state management store
export const useFolderStore = create<FolderState & FolderActions>((set) => ({
folders: [],
currentFolder: null,
isLoading: true,
readFolders: (folders: FolderWithRelations[]) => set({ folders }),
setCurrentFolder: (folder: FolderWithRelations | null) => set({ currentFolder: folder }),
createFolder: (newFolder: FolderWithRelations) => set((state) => ({
folders: [newFolder, ...state.folders]
})),
updateFolder: (updatedFolder: FolderWithRelations) => set((state) => ({
folders: state.folders.map(folder =>
folder.id === updatedFolder.id ? updatedFolder : folder
),
currentFolder: state.currentFolder?.id === updatedFolder.id
? updatedFolder
: state.currentFolder
})),
deleteFolder: (folderId: string) => set((state) => ({
folders: state.folders.filter(folder => folder.id !== folderId),
currentFolder: state.currentFolder?.id === folderId
? null
: state.currentFolder
})),
setLoading: (isLoading: boolean) => set({ isLoading }),
}));

View File

@@ -0,0 +1,44 @@
// Folder entity types
export interface Folder {
id: string;
name: string;
icon?: string;
color?: string;
isArchived: boolean;
createdAt: Date | string;
updatedAt: Date | string;
parentId?: string;
userId: string;
}
export interface FolderWithRelations extends Folder {
documents: Array<{
id: string;
title: string;
icon?: string;
updatedAt: string;
}>;
children: FolderWithRelations[];
_count: {
documents: number;
children: number;
};
}
// Folder state
export interface FolderState {
folders: FolderWithRelations[];
currentFolder: FolderWithRelations | null;
isLoading: boolean;
}
// Folder actions
export interface FolderActions {
readFolders: (folders: FolderWithRelations[]) => void;
setCurrentFolder: (folder: FolderWithRelations | null) => void;
createFolder: (folder: FolderWithRelations) => void;
updateFolder: (updatedFolder: FolderWithRelations) => void;
deleteFolder: (folderId: string) => void;
setLoading: (isLoading: boolean) => void;
}

View File

@@ -0,0 +1,13 @@
import { z } from 'zod';
// Folder validation schemas
export const folderSchema = z.object({
name: z.string().min(1, 'Name is required').max(100, 'Name is too long'),
icon: z.string().optional(),
color: z.string().optional(),
isArchived: z.boolean().default(false),
parentId: z.string().optional(),
});
export type FolderInput = z.infer<typeof folderSchema>;

View File

@@ -0,0 +1,40 @@
import { FolderWithRelations } from '../model/types';
import { cn } from '@/shared/lib/utils';
import { Folder, Calendar } from 'lucide-react';
import Link from 'next/link';
interface FolderItemProps {
folder: FolderWithRelations;
className?: string;
}
export const FolderItem = ({ folder, className }: FolderItemProps) => {
const Icon = folder.icon ?
<span className="text-2xl">{folder.icon}</span> :
<Folder className="w-6 h-6 text-gray-400" />;
return (
<Link
href={`/documents?folder=${folder.id}`}
className={cn(
"group flex items-center gap-3 p-3 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors",
className
)}
>
<div className="flex items-center justify-center w-10 h-10 rounded flex-shrink-0">
{Icon}
</div>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium truncate">{folder.name}</p>
<div className="flex items-center gap-4 text-xs text-gray-500 mt-1">
<span>{folder._count.documents} documents</span>
<div className="flex items-center gap-1">
<Calendar className="w-3 h-3" />
<span>{new Date(folder.updatedAt).toLocaleDateString()}</span>
</div>
</div>
</div>
</Link>
);
};

View File

@@ -0,0 +1,2 @@
export * from './FolderItem';

View File

@@ -0,0 +1,6 @@
// Export all entities
export * from './document';
export * from './folder';
export * from './user';
export * from './template';

View File

@@ -0,0 +1,56 @@
import type { DatabaseTemplate } from '../model/types';
import { apiGet, apiPost, apiPut, apiDelete } from '@/shared/api';
// Template API functions
export const templateApi = {
// Get all templates
getTemplates: async (): Promise<DatabaseTemplate[]> => {
try {
return await apiGet<DatabaseTemplate[]>('/api/templates');
} catch (error) {
console.error('Failed to fetch templates:', error);
throw error;
}
},
// Get public templates
getPublicTemplates: async (): Promise<DatabaseTemplate[]> => {
try {
return await apiGet<DatabaseTemplate[]>('/api/templates?public=true');
} catch (error) {
console.error('Failed to fetch public templates:', error);
throw error;
}
},
// Create a new template
createTemplate: async (newTemplate: Partial<DatabaseTemplate>): Promise<DatabaseTemplate> => {
try {
return await apiPost<DatabaseTemplate>('/api/templates', newTemplate);
} catch (error) {
console.error('Failed to create template:', error);
throw error;
}
},
// Update a template
updateTemplate: async (id: string, updateData: Partial<DatabaseTemplate>): Promise<DatabaseTemplate> => {
try {
return await apiPut<DatabaseTemplate>(`/api/templates/${id}`, updateData);
} catch (error) {
console.error('Failed to update template:', error);
throw error;
}
},
// Delete a template
deleteTemplate: async (id: string): Promise<void> => {
try {
await apiDelete<void>(`/api/templates/${id}`);
} catch (error) {
console.error('Failed to delete template:', error);
throw error;
}
},
};

View File

@@ -0,0 +1,4 @@
export * from './api';
export * from './model';
export * from './ui';

View File

@@ -0,0 +1,4 @@
export * from './types';
export * from './store';
export * from './validation';

View File

@@ -0,0 +1,36 @@
import { create } from 'zustand';
import { DatabaseTemplate, TemplateState, TemplateActions } from './types';
// Template state management store
export const useTemplateStore = create<TemplateState & TemplateActions>((set) => ({
templates: [],
currentTemplate: null,
isLoading: true,
readTemplates: (templates: DatabaseTemplate[]) => set({ templates }),
setCurrentTemplate: (template: DatabaseTemplate | null) => set({ currentTemplate: template }),
createTemplate: (newTemplate: DatabaseTemplate) => set((state) => ({
templates: [newTemplate, ...state.templates]
})),
updateTemplate: (updatedTemplate: DatabaseTemplate) => set((state) => ({
templates: state.templates.map(template =>
template.id === updatedTemplate.id ? updatedTemplate : template
),
currentTemplate: state.currentTemplate?.id === updatedTemplate.id
? updatedTemplate
: state.currentTemplate
})),
deleteTemplate: (templateId: string) => set((state) => ({
templates: state.templates.filter(template => template.id !== templateId),
currentTemplate: state.currentTemplate?.id === templateId
? null
: state.currentTemplate
})),
setLoading: (isLoading: boolean) => set({ isLoading }),
}));

View File

@@ -0,0 +1,49 @@
// Template entity types
export interface Template {
id: string;
name: string;
description?: string;
icon: React.ReactElement | string;
content: any;
}
export interface DatabaseTemplate {
id: string;
name: string;
description?: string;
category: string;
title: string;
content: any;
isPublic: boolean;
createdAt: string;
updatedAt: string;
userId: string;
}
export interface TemplateBrowserProps {
isOpen: boolean;
onClose: () => void;
onSelectTemplate: (template: DatabaseTemplate) => void;
}
export interface TemplateSelectorProps {
onSelectTemplate: (template: Template | DatabaseTemplate) => void;
}
// Template state
export interface TemplateState {
templates: DatabaseTemplate[];
currentTemplate: DatabaseTemplate | null;
isLoading: boolean;
}
// Template actions
export interface TemplateActions {
readTemplates: (templates: DatabaseTemplate[]) => void;
setCurrentTemplate: (template: DatabaseTemplate | null) => void;
createTemplate: (template: DatabaseTemplate) => void;
updateTemplate: (updatedTemplate: DatabaseTemplate) => void;
deleteTemplate: (templateId: string) => void;
setLoading: (isLoading: boolean) => void;
}

View File

@@ -0,0 +1,14 @@
import { z } from 'zod';
// Template validation schemas
export const templateSchema = z.object({
name: z.string().min(1, 'Name is required').max(200, 'Name is too long'),
description: z.string().optional(),
category: z.string().default('General'),
title: z.string().min(1, 'Title is required').max(200, 'Title is too long'),
content: z.any().optional(),
isPublic: z.boolean().default(false),
});
export type TemplateInput = z.infer<typeof templateSchema>;

View File

@@ -0,0 +1,6 @@
// Template UI components would be defined here
// export * from './TemplateCard';
// export * from './TemplateSelector';
export {};

View File

@@ -0,0 +1,170 @@
import type { Todo, CreateTodoData, UpdateTodoData } from '../model/types'
const TODO_API_URL = process.env.NEXT_PUBLIC_TODO_API_URL || 'http://localhost:3002'
// API 응답 타입
interface ApiTodo {
id: number
title: string
description?: string | null
completed: boolean
createdAt: string
updatedAt: string
}
interface ApiResponse<T> {
success: boolean
data?: T
error?: string
}
// 백엔드 Todo를 프론트엔드 Todo로 변환
const mapApiTodoToTodo = (apiTodo: ApiTodo): Todo => {
return {
id: String(apiTodo.id),
title: apiTodo.title,
description: apiTodo.description || undefined,
completed: apiTodo.completed,
createdAt: new Date(apiTodo.createdAt),
updatedAt: new Date(apiTodo.updatedAt),
}
}
/**
* 모든 TODO 가져오기
*/
export const fetchTodos = async (): Promise<Todo[]> => {
try {
const response = await fetch(`${TODO_API_URL}/api/todos`, {
method: 'GET',
headers: {
'Content-Type': 'application/json',
},
credentials: 'include',
})
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`)
}
const result: ApiResponse<ApiTodo[]> = await response.json()
if (!result.success || !result.data) {
throw new Error(result.error || 'Failed to fetch todos')
}
return result.data.map(mapApiTodoToTodo)
} catch (error) {
console.error('Error fetching todos:', error)
throw error
}
}
/**
* TODO 생성
*/
export const createTodo = async (data: CreateTodoData): Promise<Todo> => {
try {
const response = await fetch(`${TODO_API_URL}/api/todos`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
credentials: 'include',
body: JSON.stringify({
title: data.title,
description: data.description || null,
completed: false,
}),
})
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`)
}
const result: ApiResponse<ApiTodo> = await response.json()
if (!result.success || !result.data) {
throw new Error(result.error || 'Failed to create todo')
}
return mapApiTodoToTodo(result.data)
} catch (error) {
console.error('Error creating todo:', error)
throw error
}
}
/**
* TODO 업데이트
*/
export const updateTodo = async (id: string, data: UpdateTodoData): Promise<Todo> => {
try {
const response = await fetch(`${TODO_API_URL}/api/todos/${id}`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
},
credentials: 'include',
body: JSON.stringify({
title: data.title,
description: data.description,
completed: data.completed,
}),
})
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`)
}
const result: ApiResponse<ApiTodo> = await response.json()
if (!result.success || !result.data) {
throw new Error(result.error || 'Failed to update todo')
}
return mapApiTodoToTodo(result.data)
} catch (error) {
console.error('Error updating todo:', error)
throw error
}
}
/**
* TODO 삭제
*/
export const deleteTodo = async (id: string): Promise<void> => {
try {
const response = await fetch(`${TODO_API_URL}/api/todos/${id}`, {
method: 'DELETE',
headers: {
'Content-Type': 'application/json',
},
credentials: 'include',
})
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`)
}
const result: ApiResponse<null> = await response.json()
if (!result.success) {
throw new Error(result.error || 'Failed to delete todo')
}
} catch (error) {
console.error('Error deleting todo:', error)
throw error
}
}
/**
* Todo API
*/
export const todoApi = {
fetchTodos,
createTodo,
updateTodo,
deleteTodo,
}

View File

@@ -0,0 +1,3 @@
export * from './model'
export * from './api'

View File

@@ -0,0 +1,3 @@
export * from './types'
export * from './store'

View File

@@ -0,0 +1,133 @@
import { create } from 'zustand'
import { Todo, CreateTodoData, UpdateTodoData, TodoStore, TodoFilter } from './types'
import { fetchTodos, createTodo, updateTodo, deleteTodo } from '../api'
export const useTodoStore = create<TodoStore>()((set, get) => ({
// State
todos: [],
filter: 'all',
isLoading: false,
error: null,
// Actions
addTodo: async (data: CreateTodoData) => {
if (!data.title?.trim()) {
console.warn('Todo title is required')
return
}
try {
const newTodo = await createTodo(data)
set((state) => ({
todos: [newTodo, ...state.todos]
}))
} catch (error) {
console.error('Failed to create todo:', error)
set({ error: 'Failed to create todo' })
}
},
updateTodo: async (id: string, data: UpdateTodoData) => {
if (!id) {
console.warn('Todo ID is required for update')
return
}
try {
const updatedTodo = await updateTodo(id, data)
set((state) => ({
todos: state.todos.map((todo) =>
todo.id === id ? updatedTodo : todo
)
}))
} catch (error) {
console.error('Failed to update todo:', error)
set({ error: 'Failed to update todo' })
}
},
deleteTodo: async (id: string) => {
if (!id) {
console.warn('Todo ID is required for deletion')
return
}
try {
await deleteTodo(id)
set((state) => ({
todos: state.todos.filter((todo) => todo.id !== id)
}))
} catch (error) {
console.error('Failed to delete todo:', error)
set({ error: 'Failed to delete todo' })
}
},
toggleTodo: async (id: string) => {
if (!id) {
console.warn('Todo ID is required for toggle')
return
}
const todo = get().todos.find((t) => t.id === id)
if (!todo) return
try {
await get().updateTodo(id, { completed: !todo.completed })
} catch (error) {
console.error('Failed to toggle todo:', error)
}
},
loadTodos: async () => {
set({ isLoading: true, error: null })
try {
const todos = await fetchTodos()
set({ todos, isLoading: false })
} catch (error) {
console.error('Failed to load todos:', error)
set({ error: 'Failed to load todos', isLoading: false })
}
},
setFilter: (filter: TodoFilter) => {
set({ filter })
},
clearCompleted: async () => {
const completedTodos = get().todos.filter((todo) => todo.completed)
try {
await Promise.all(completedTodos.map((todo) => deleteTodo(todo.id)))
set((state) => ({
todos: state.todos.filter((todo) => !todo.completed)
}))
} catch (error) {
console.error('Failed to clear completed todos:', error)
set({ error: 'Failed to clear completed todos' })
}
},
}))
// Selectors
export const useFilteredTodos = () => {
const { todos, filter } = useTodoStore()
if (!Array.isArray(todos)) {
return []
}
return todos.filter((todo) => {
if (!todo || typeof todo !== 'object') {
return false
}
const matchesFilter =
filter === 'all' ||
(filter === 'active' && !todo.completed) ||
(filter === 'completed' && todo.completed)
return matchesFilter
})
}

View File

@@ -0,0 +1,42 @@
export interface Todo {
id: string
title: string
description?: string
completed: boolean
createdAt: Date
updatedAt: Date
}
export interface CreateTodoData {
title: string
description?: string
}
export interface UpdateTodoData {
title?: string
description?: string
completed?: boolean
}
export type TodoFilter = 'all' | 'active' | 'completed'
// Store types
export interface TodoState {
todos: Todo[]
filter: TodoFilter
isLoading: boolean
error: string | null
}
export interface TodoActions {
addTodo: (data: CreateTodoData) => Promise<void>
updateTodo: (id: string, data: UpdateTodoData) => Promise<void>
deleteTodo: (id: string) => Promise<void>
toggleTodo: (id: string) => Promise<void>
loadTodos: () => Promise<void>
setFilter: (filter: TodoFilter) => void
clearCompleted: () => Promise<void>
}
export type TodoStore = TodoState & TodoActions

View File

@@ -0,0 +1,132 @@
'use client'
import { Todo } from '../model/types'
import { Checkbox } from '@/shared/ui/checkbox'
import { Button } from '@/shared/ui/button'
import { Trash2, Edit2 } from 'lucide-react'
import { cn } from '@/shared/lib/utils'
import { useState } from 'react'
import { Input } from '@/shared/ui/input'
interface TodoItemProps {
todo: Todo
onToggle: (id: string) => void
onDelete: (id: string) => void
onUpdate: (id: string, title: string, description?: string) => void
}
export function TodoItem({ todo, onToggle, onDelete, onUpdate }: TodoItemProps) {
const [isEditing, setIsEditing] = useState(false)
const [editTitle, setEditTitle] = useState(todo.title)
const [editDescription, setEditDescription] = useState(todo.description || '')
const handleSave = () => {
if (editTitle.trim()) {
onUpdate(todo.id, editTitle, editDescription)
setIsEditing(false)
}
}
const handleCancel = () => {
setEditTitle(todo.title)
setEditDescription(todo.description || '')
setIsEditing(false)
}
if (isEditing) {
return (
<div className="flex flex-col gap-2 p-3 border rounded-lg bg-background">
<Input
value={editTitle}
onChange={(e) => setEditTitle(e.target.value)}
placeholder="Todo 제목"
className="font-medium"
autoFocus
onKeyDown={(e) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault()
handleSave()
} else if (e.key === 'Escape') {
handleCancel()
}
}}
/>
<Input
value={editDescription}
onChange={(e) => setEditDescription(e.target.value)}
placeholder="설명 (선택사항)"
onKeyDown={(e) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault()
handleSave()
} else if (e.key === 'Escape') {
handleCancel()
}
}}
/>
<div className="flex gap-2 justify-end">
<Button size="sm" variant="ghost" onClick={handleCancel}>
</Button>
<Button size="sm" onClick={handleSave}>
</Button>
</div>
</div>
)
}
return (
<div
className={cn(
"flex items-start gap-3 p-3 rounded-lg border bg-background",
"hover:bg-accent/50 transition-colors group"
)}
>
<Checkbox
checked={todo.completed}
onCheckedChange={() => onToggle(todo.id)}
className="mt-0.5"
/>
<div className="flex-1 min-w-0">
<div
className={cn(
"font-medium text-sm",
todo.completed && "line-through text-muted-foreground"
)}
>
{todo.title}
</div>
{todo.description && (
<div className="text-xs text-muted-foreground mt-1">
{todo.description}
</div>
)}
<div className="text-xs text-muted-foreground mt-1">
{new Date(todo.createdAt).toLocaleDateString('ko-KR')}
</div>
</div>
<div className="flex gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
<Button
size="icon"
variant="ghost"
className="h-8 w-8"
onClick={() => setIsEditing(true)}
>
<Edit2 className="h-4 w-4" />
</Button>
<Button
size="icon"
variant="ghost"
className="h-8 w-8 text-destructive hover:text-destructive"
onClick={() => onDelete(todo.id)}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</div>
)
}

View File

@@ -0,0 +1,53 @@
'use client'
import { TodoItem } from './TodoItem'
import { useTodoStore, useFilteredTodos } from '../model/store'
import { Spinner } from '@/shared/ui/spinner'
export function TodoList() {
const { updateTodo, deleteTodo, toggleTodo, isLoading, error } = useTodoStore()
const filteredTodos = useFilteredTodos()
if (isLoading) {
return (
<div className="flex items-center justify-center p-8">
<Spinner />
</div>
)
}
if (error) {
return (
<div className="text-center p-8 text-destructive">
{error}
</div>
)
}
if (filteredTodos.length === 0) {
return (
<div className="text-center p-8 text-muted-foreground">
</div>
)
}
const handleUpdate = async (id: string, title: string, description?: string) => {
await updateTodo(id, { title, description })
}
return (
<div className="flex flex-col gap-2">
{filteredTodos.map((todo) => (
<TodoItem
key={todo.id}
todo={todo}
onToggle={toggleTodo}
onDelete={deleteTodo}
onUpdate={handleUpdate}
/>
))}
</div>
)
}

View File

@@ -0,0 +1,3 @@
export * from './TodoItem'
export * from './TodoList'

View File

@@ -0,0 +1,53 @@
import type { User } from '../model/types';
import { apiGet, apiPost } from '@/shared/api';
// User API functions
export const userApi = {
// Get current user
getCurrentUser: async (): Promise<User> => {
try {
return await apiGet<User>('/api/auth/me');
} catch (error) {
console.error('Failed to fetch user:', error);
throw error;
}
},
// Login
login: async (email: string, password: string): Promise<{ user: User; token: string }> => {
try {
return await apiPost<{ user: User; token: string }>('/api/auth/login', {
email,
password,
});
} catch (error) {
console.error('Failed to login:', error);
throw error;
}
},
// Register
register: async (email: string, password: string, name?: string): Promise<{ user: User; token: string }> => {
try {
return await apiPost<{ user: User; token: string }>('/api/auth/register', {
email,
password,
name,
});
} catch (error) {
console.error('Failed to register:', error);
throw error;
}
},
// Logout
logout: async (): Promise<void> => {
try {
await apiPost<void>('/api/auth/logout');
} catch (error) {
console.error('Failed to logout:', error);
throw error;
}
},
};

View File

@@ -0,0 +1,4 @@
export * from './api';
export * from './model';
export * from './ui';

View File

@@ -0,0 +1,4 @@
export * from './types';
export * from './store';
export * from './validation';

View File

@@ -0,0 +1,13 @@
import { create } from 'zustand';
import { UserState, UserActions, User } from './types';
// User state management store
export const useUserStore = create<UserState & UserActions>((set) => ({
user: null,
isLoading: true,
setUser: (user: User | null) => set({ user }),
setLoading: (isLoading: boolean) => set({ isLoading }),
}));

View File

@@ -0,0 +1,27 @@
// User entity types
export interface User {
id: string;
email: string;
name?: string;
image?: string;
}
export interface AuthContextType {
user: User | null;
isLoading: boolean;
login: (token: string, user: User) => void;
logout: () => void;
}
// User state
export interface UserState {
user: User | null;
isLoading: boolean;
}
// User actions
export interface UserActions {
setUser: (user: User | null) => void;
setLoading: (isLoading: boolean) => void;
}

View File

@@ -0,0 +1,11 @@
import { z } from 'zod';
// User validation schemas
export const userSchema = z.object({
email: z.string().email('Invalid email address'),
name: z.string().optional(),
image: z.string().url().optional(),
});
export type UserInput = z.infer<typeof userSchema>;

View File

@@ -0,0 +1,6 @@
// User UI components would be defined here
// export * from './UserAvatar';
// export * from './UserProfile';
export {};

View File

@@ -0,0 +1,3 @@
export * from './model';
export * from './ui';

View File

@@ -0,0 +1,9 @@
export * from './store';
export * from './use-document-data';
export * from './use-document-save';
export * from './use-document-actions';
export * from './use-document-headings';
export * from './use-document-utils';
export * from './use-document-templates';
export * from './use-sidebar-search';

View File

@@ -0,0 +1,70 @@
import { useCallback } from 'react';
import { documentApi } from '@/entities/document/api';
import { useDocumentStore } from '@/entities/document/model/store';
import type { DocumentWithRelations, DocumentInput } from '@/entities/document/model';
// API 호출과 전역 상태 관리를 통합하는 훅
export const useDocumentEditStore = () => {
const entityStore = useDocumentStore.getState();
const readDocuments = useCallback(async () => {
entityStore.setLoading(true);
try {
entityStore.readDocuments(await documentApi.getDocuments());
} finally {
entityStore.setLoading(false);
}
}, [entityStore]);
const readDocument = useCallback(async (documentId: string) => {
entityStore.setLoading(true);
try {
const document = await documentApi.getDocument(documentId);
entityStore.setCurrentDocument(document);
return document;
} finally {
entityStore.setLoading(false);
}
}, [entityStore]);
const createDocument = useCallback(async (newDocument: DocumentInput) => {
entityStore.setLoading(true);
try {
const created = await documentApi.createDocument(newDocument);
entityStore.createDocument(created as DocumentWithRelations);
return created;
} finally {
entityStore.setLoading(false);
}
}, [entityStore]);
const updateDocument = useCallback(async (documentId: string, updateData: Partial<DocumentWithRelations>) => {
entityStore.setLoading(true);
try {
const updated = await documentApi.updateDocument(documentId, updateData);
entityStore.updateDocument(updated as DocumentWithRelations);
return updated;
} finally {
entityStore.setLoading(false);
}
}, [entityStore]);
const deleteDocument = useCallback(async (documentId: string) => {
entityStore.setLoading(true);
try {
await documentApi.deleteDocument(documentId);
entityStore.deleteDocument(documentId);
} finally {
entityStore.setLoading(false);
}
}, [entityStore]);
return {
readDocuments,
readDocument,
createDocument,
updateDocument,
deleteDocument,
};
};

View File

@@ -0,0 +1,75 @@
import { useState, useCallback } from "react";
import { useRouter } from "next/navigation";
import { documentApi } from "@/entities/document/api";
interface UseDocumentActionsProps {
documentId: string;
onPublishChange?: (isPublished: boolean) => void;
}
/**
* 문서 액션 (공유, 삭제 등)
*/
export const useDocumentActions = ({
documentId,
onPublishChange
}: UseDocumentActionsProps) => {
const router = useRouter();
const [isDeleting, setIsDeleting] = useState(false);
// Delete document
const deleteDocument = useCallback(async () => {
if (!confirm("Are you sure you want to delete this document?")) return;
try {
setIsDeleting(true);
await documentApi.deleteDocument(documentId);
router.push("/documents");
} catch (error) {
console.error("Error deleting document:", error);
} finally {
setIsDeleting(false);
}
}, [documentId, router]);
// Share document
const shareDocument = useCallback(async () => {
try {
await documentApi.updateDocument(documentId, {
isPublished: true,
});
const shareUrl = `${window.location.origin}/share/${documentId}`;
alert('문서가 공개되었습니다!\n\n공유 링크:\n' + shareUrl);
onPublishChange?.(true);
} catch (error) {
console.error('Error sharing document:', error);
alert('문서 공유에 실패했습니다');
}
}, [documentId, onPublishChange]);
// Unshare document
const unshareDocument = useCallback(async () => {
try {
await documentApi.updateDocument(documentId, {
isPublished: false,
});
alert('Document is no longer shared!');
onPublishChange?.(false);
} catch (error) {
console.error('Error unsharing document:', error);
alert('Failed to unshare document');
}
}, [documentId, onPublishChange]);
return {
deleteDocument,
shareDocument,
unshareDocument,
isDeleting,
};
};

View File

@@ -0,0 +1,98 @@
import { useState, useEffect, useCallback } from "react";
import { useRouter } from "next/navigation";
import { documentApi } from "@/entities/document/api";
import type { DocumentWithRelations, DocumentListItem } from "@/entities/document/model";
interface UseDocumentDataProps {
documentId: string;
}
/**
* 문서 데이터 조회 및 기본 상태 관리
*/
export const useDocumentData = ({ documentId }: UseDocumentDataProps) => {
const router = useRouter();
const [document, setDocument] = useState<DocumentWithRelations | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [title, setTitle] = useState("");
const [content, setContent] = useState<any>(null);
const [availableDocuments, setAvailableDocuments] = useState<DocumentListItem[]>([]);
// Fetch document
const fetchDocument = useCallback(async () => {
try {
const data = await documentApi.getDocument(documentId);
setDocument(data);
setTitle(data.title);
setContent(data.content || {
type: 'doc',
content: [
{
type: 'paragraph',
content: [
{
type: 'text',
text: 'Start writing...',
},
],
},
],
});
} catch (error: any) {
console.error("Error fetching document:", error);
if (error.message?.includes('404')) {
router.push("/documents");
}
} finally {
setIsLoading(false);
}
}, [documentId, router]);
// Fetch available documents for linking
const fetchAvailableDocuments = useCallback(async () => {
try {
const documents = await documentApi.getDocuments();
const filteredDocs = documents
.filter((doc: DocumentWithRelations) => doc.id !== documentId)
.map((doc: DocumentWithRelations) => ({
id: doc.id,
title: doc.title,
updatedAt: typeof doc.updatedAt === 'string' ? doc.updatedAt : new Date(doc.updatedAt).toISOString()
}));
setAvailableDocuments(filteredDocs);
} catch (error) {
console.error("Error fetching available documents:", error);
}
}, [documentId]);
// Refresh document data
const refreshDocument = useCallback(() => {
setIsLoading(true);
fetchDocument();
}, [fetchDocument]);
// Update local document state
const updateDocument = useCallback((updatedDoc: DocumentWithRelations) => {
setDocument(updatedDoc);
}, []);
useEffect(() => {
if (documentId) {
fetchDocument();
fetchAvailableDocuments();
}
}, [documentId, fetchDocument, fetchAvailableDocuments]);
return {
document,
isLoading,
title,
setTitle,
content,
setContent,
availableDocuments,
refreshDocument,
updateDocument,
};
};

View File

@@ -0,0 +1,219 @@
import { useState, useEffect } from 'react';
import type { HeadingItem } from '@/shared/types';
export const useDocumentHeadings = (content: any) => {
const [headingInstances] = useState<Map<string, HTMLElement[]>>(new Map());
const [headings, setHeadings] = useState<HeadingItem[]>([]);
const [treeHeadings, setTreeHeadings] = useState<HeadingItem[]>([]);
const [activeHeading, setActiveHeading] = useState<string>("");
// Convert flat headings to tree structure
const buildHeadingTree = (flatHeadings: HeadingItem[]): HeadingItem[] => {
if (flatHeadings.length === 0) return [];
const tree: HeadingItem[] = [];
const stack: HeadingItem[] = [];
flatHeadings.forEach(heading => {
const newHeading = { ...heading, children: [], isExpanded: true };
// Find the correct parent by looking at the stack
while (stack.length > 0 && stack[stack.length - 1].level >= heading.level) {
stack.pop();
}
if (stack.length === 0) {
// This is a root level heading
tree.push(newHeading);
} else {
// This is a child of the last heading in stack
const parent = stack[stack.length - 1];
if (!parent.children) parent.children = [];
parent.children.push(newHeading);
}
stack.push(newHeading);
});
return tree;
};
// Extract headings from content
useEffect(() => {
if (!content?.content) return;
const extractHeadings = (node: any, level = 0): HeadingItem[] => {
if (!node) return [];
let result: HeadingItem[] = [];
if (node.type === 'heading' && node.attrs?.level) {
const text = node.content?.map((c: any) => c.text || '').join('') || '';
// Keep alphanumeric characters (including Korean), spaces become hyphens
const id = text.toLowerCase()
.replace(/\s+/g, '-') // Replace spaces with hyphens
.replace(/[^a-z0-9\u3131-\u3163\uac00-\ud7a3-]+/g, '') // Keep only letters, numbers, Korean chars, and hyphens
.replace(/-+/g, '-') // Replace multiple hyphens with single hyphen
.replace(/^-|-$/g, ''); // Remove leading/trailing hyphens
result.push({
id,
text,
level: node.attrs.level
});
}
if (node.content) {
node.content.forEach((child: any) => {
result.push(...extractHeadings(child, level));
});
}
return result;
};
const extractedHeadings = extractHeadings(content);
// Ensure unique IDs by adding a counter suffix if duplicates exist
const idCounts = new Map<string, number>();
const uniqueHeadings = extractedHeadings.map((heading, index) => {
const baseId = heading.id || 'heading';
// Count how many times we've seen this base ID
const count = idCounts.get(baseId) || 0;
idCounts.set(baseId, count + 1);
// If this is the first occurrence, use the base ID
// Otherwise, append a suffix
const uniqueId = count === 0 ? baseId : `${baseId}-${count}`;
return {
...heading,
id: uniqueId,
_index: index // Add sequential index for reference
};
});
setHeadings(uniqueHeadings);
setTreeHeadings(buildHeadingTree(uniqueHeadings));
}, [content]);
// Track active heading based on scroll position
useEffect(() => {
const handleScroll = () => {
const headingElements = headings.map(h => {
const element = document.querySelector(`[data-heading-id="${h.id}"]`);
return { ...h, element: element as HTMLElement };
}).filter(h => h.element);
let currentHeading = "";
const scrollPosition = window.scrollY + 100;
for (let i = headingElements.length - 1; i >= 0; i--) {
const heading = headingElements[i];
if (heading.element && heading.element.offsetTop <= scrollPosition) {
currentHeading = heading.id;
break;
}
}
setActiveHeading(currentHeading);
};
window.addEventListener('scroll', handleScroll);
return () => window.removeEventListener('scroll', handleScroll);
}, [headings]);
const toggleHeadingExpansion = (headingId: string) => {
const updateHeadingExpansion = (headings: HeadingItem[]): HeadingItem[] => {
return headings.map(heading => {
if (heading.id === headingId) {
return { ...heading, isExpanded: !heading.isExpanded };
}
if (heading.children) {
return { ...heading, children: updateHeadingExpansion(heading.children) };
}
return heading;
});
};
setTreeHeadings(updateHeadingExpansion(treeHeadings));
};
const scrollToHeading = (headingId: string) => {
const selector1 = `[data-heading-id="${headingId}"]`;
const allMatches = document.querySelectorAll(selector1);
// Find the position of this heading in the tree structure
let targetIndex = 0;
const findHeadingInTree = (items: HeadingItem[], targetId: string, path: number[] = []): number[] | null => {
for (let i = 0; i < items.length; i++) {
const currentPath = [...path, i];
if (items[i].id === targetId) {
return currentPath;
}
if (items[i].children) {
const result = findHeadingInTree(items[i].children!, targetId, currentPath);
if (result) return result;
}
}
return null;
};
const targetPath = findHeadingInTree(treeHeadings, headingId);
if (targetPath && targetPath.length > 0) {
// Count how many headings with the same ID appear before this position
let count = 0;
const countBefore = (items: HeadingItem[], targetId: string, targetPath: number[], currentPath: number[] = []): number => {
let count = 0;
for (let i = 0; i < items.length; i++) {
const path = [...currentPath, i];
// Check if this is before the target path
if (items[i].id === targetId) {
let isBefore = true;
for (let j = 0; j < Math.min(path.length, targetPath.length); j++) {
if (path[j] > targetPath[j]) {
isBefore = false;
break;
} else if (path[j] < targetPath[j]) {
break;
}
}
if (isBefore && JSON.stringify(path) !== JSON.stringify(targetPath)) {
count++;
}
}
if (items[i].children) {
count += countBefore(items[i].children!, targetId, targetPath, path);
}
}
return count;
};
targetIndex = countBefore(treeHeadings, headingId, targetPath);
}
// Get the element at the calculated index
if (allMatches.length > 0 && targetIndex < allMatches.length) {
const element = allMatches[targetIndex] as HTMLElement;
element.scrollIntoView({ behavior: 'smooth', block: 'start' });
} else if (allMatches.length > 0) {
// Fallback to first match
const element = allMatches[0] as HTMLElement;
element.scrollIntoView({ behavior: 'smooth', block: 'start' });
}
};
return {
headings,
treeHeadings,
activeHeading,
toggleHeadingExpansion,
scrollToHeading,
};
};

View File

@@ -0,0 +1,78 @@
import { useState, useEffect, useCallback } from "react";
import { documentApi } from "@/entities/document/api";
import type { DocumentWithRelations } from "@/entities/document/model";
interface UseDocumentSaveProps {
documentId: string;
title: string;
content: any;
onSaveSuccess?: (document: DocumentWithRelations) => void;
}
/**
* 문서 저장 로직 (수동 저장, 자동 저장)
*/
export const useDocumentSave = ({
documentId,
title,
content,
onSaveSuccess
}: UseDocumentSaveProps) => {
const [isSaving, setIsSaving] = useState(false);
const [isAutoSaving, setIsAutoSaving] = useState(false);
const [lastSaved, setLastSaved] = useState<Date | null>(null);
// Save document
const saveDocument = useCallback(async () => {
try {
setIsSaving(true);
const updatedDocument = await documentApi.updateDocument(documentId, {
title: title || "Untitled",
content: content,
});
setLastSaved(new Date());
onSaveSuccess?.(updatedDocument as DocumentWithRelations);
} catch (error) {
console.error("Error saving document:", error);
} finally {
setIsSaving(false);
}
}, [documentId, title, content, onSaveSuccess]);
// Auto-save function
const autoSave = useCallback(async () => {
if (!title.trim() && !content) return;
try {
setIsAutoSaving(true);
await documentApi.updateDocument(documentId, {
title: title || "Untitled",
content: content,
});
setLastSaved(new Date());
} catch (error) {
console.error("Error auto-saving document:", error);
} finally {
setIsAutoSaving(false);
}
}, [documentId, title, content]);
// Auto-save effect
useEffect(() => {
if (!title.trim() && !content) return;
const timeoutId = setTimeout(() => {
autoSave();
}, 2000); // Auto-save after 2 seconds of inactivity
return () => clearTimeout(timeoutId);
}, [title, content, autoSave]);
return {
saveDocument,
isSaving,
isAutoSaving,
lastSaved,
};
};

View File

@@ -0,0 +1,40 @@
import { useCallback } from "react";
import { apiPost } from "@/shared/lib/api-client";
interface UseDocumentTemplatesProps {
onApply?: (content: any, title?: string) => void;
}
/**
* 템플릿 생성 및 적용
*/
export const useDocumentTemplates = ({ onApply }: UseDocumentTemplatesProps = {}) => {
// Create template from document
const createTemplate = useCallback(async (templateData: any) => {
try {
const result = await apiPost('/api/templates', templateData);
console.log('Template created successfully:', result);
alert(`Template "${templateData.name}" created successfully!`);
} catch (error) {
console.error('Error creating template:', error);
throw error;
}
}, []);
// Apply template to document
const applyTemplate = useCallback((template: any) => {
if (template.content) {
const title = template.title || undefined;
onApply?.(template.content, title);
alert(`Template "${template.name}" applied successfully!`);
}
}, [onApply]);
return {
createTemplate,
applyTemplate,
};
};

View File

@@ -0,0 +1,37 @@
import { useCallback } from "react";
/**
* 문서 관련 유틸리티 함수
*/
export const useDocumentUtils = () => {
// Calculate word count
const getWordCount = useCallback((content: any): number => {
if (!content?.content) return 0;
const extractText = (node: any): string => {
if (node.type === 'text') return node.text || '';
if (node.content) return node.content.map(extractText).join(' ');
return '';
};
const text = content.content.map(extractText).join(' ');
return text.trim().split(/\s+/).filter((word: string) => word.length > 0).length;
}, []);
// Format date helper
const formatDate = useCallback((date: Date): string => {
const now = new Date();
const diffInMinutes = Math.floor((now.getTime() - date.getTime()) / (1000 * 60));
if (diffInMinutes < 1) return "Just now";
if (diffInMinutes < 60) return `${diffInMinutes}m ago`;
if (diffInMinutes < 1440) return `${Math.floor(diffInMinutes / 60)}h ago`;
return date.toLocaleDateString();
}, []);
return {
getWordCount,
formatDate,
};
};

View File

@@ -0,0 +1,204 @@
import { useState } from 'react';
export interface SearchResult {
type: string;
text: string;
id?: string;
path?: string;
}
export const useSidebarSearch = (content: any) => {
const [searchQuery, setSearchQuery] = useState('');
const [searchResults, setSearchResults] = useState<SearchResult[]>([]);
const [showSearchResults, setShowSearchResults] = useState(false);
// Search function to extract text from content
const extractTextFromNode = (node: any, path: string = ''): SearchResult[] => {
if (!node) return [];
let results: SearchResult[] = [];
// Extract text from various node types
if (node.content) {
const text = node.content.map((c: any) => c.text || '').join('');
if (text.trim()) {
let id;
if (node.type === 'heading' && node.attrs?.level) {
// Keep alphanumeric characters (including Korean), spaces become hyphens
const headingText = text.toLowerCase()
.replace(/\s+/g, '-') // Replace spaces with hyphens
.replace(/[^a-z0-9\u3131-\u3163\uac00-\ud7a3-]+/g, '') // Keep only letters, numbers, Korean chars, and hyphens
.replace(/-+/g, '-') // Replace multiple hyphens with single hyphen
.replace(/^-|-$/g, ''); // Remove leading/trailing hyphens
id = headingText;
}
results.push({
text,
type: node.type,
id,
path
});
}
}
// Recursively process children
if (node.content && Array.isArray(node.content)) {
node.content.forEach((child: any, index: number) => {
const childPath = path ? `${path}.${node.type}[${index}]` : `${node.type}[${index}]`;
results.push(...extractTextFromNode(child, childPath));
});
}
return results;
};
// Handle search
const handleSearch = () => {
if (!searchQuery.trim()) {
setSearchResults([]);
setShowSearchResults(false);
return;
}
if (!content?.content) {
setSearchResults([]);
setShowSearchResults(false);
return;
}
const query = searchQuery.toLowerCase();
const extractedTexts = extractTextFromNode(content);
// Count occurrences for unique IDs (same as document-overview)
const idCounts = new Map<string, number>();
const matches = extractedTexts
.filter(item => item.text.toLowerCase().includes(query))
.map(item => {
// Ensure unique IDs for search results
if (item.id) {
const count = idCounts.get(item.id) || 0;
idCounts.set(item.id, count + 1);
const uniqueId = count === 0 ? item.id : `${item.id}-${count}`;
return {
...item,
id: uniqueId
};
}
return item;
});
setSearchResults(matches);
setShowSearchResults(true);
};
// Handle keyboard events
const handleSearchKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === 'Enter') {
handleSearch();
}
};
// Clear search
const handleClearSearch = () => {
setSearchQuery('');
setSearchResults([]);
setShowSearchResults(false);
};
// Navigate to search result (using same algorithm as Document Overview)
const handleNavigateToResult = (result: SearchResult) => {
// Try to find using data-heading-id attribute first (for headings)
if (result.id) {
const element = document.querySelector(`[data-heading-id="${result.id}"]`);
if (element) {
scrollAndHighlight(element as HTMLElement);
return;
}
// Also check data-node-view-wrapper which wraps the actual heading
const proseMirror = document.querySelector('.ProseMirror');
if (proseMirror) {
const allHeadings = proseMirror.querySelectorAll('h1, h2, h3, h4, h5, h6');
for (const heading of Array.from(allHeadings)) {
const wrapper = heading.closest('[data-node-view-wrapper]');
if (wrapper) {
const wrapperId = wrapper.getAttribute('data-heading-id');
if (wrapperId === result.id) {
scrollAndHighlight(wrapper as HTMLElement);
return;
}
}
// Also check text-based matching with same ID logic
const headingText = heading.textContent?.toLowerCase()
.replace(/\s+/g, '-')
.replace(/[^a-z0-9\u3131-\u3163\uac00-\ud7a3-]+/g, '')
.replace(/-+/g, '-')
.replace(/^-|-$/g, '') || '';
if (headingText === result.id) {
scrollAndHighlight(heading as HTMLElement);
return;
}
}
}
}
// Try to find in ProseMirror editor content by text
const proseMirror = document.querySelector('.ProseMirror');
if (proseMirror) {
const allElements = proseMirror.querySelectorAll('h1, h2, h3, h4, h5, h6, p, li, blockquote');
// Try to find by text content match
for (const el of Array.from(allElements)) {
const elementText = el.textContent || '';
if (elementText.trim() === result.text.trim()) {
scrollAndHighlight(el as HTMLElement);
return;
}
}
}
};
const scrollAndHighlight = (element: HTMLElement) => {
// Simple and reliable scroll (same as Document Overview)
element.scrollIntoView({ behavior: 'smooth', block: 'start' });
// Add highlight effect
element.classList.add('bg-yellow-200', 'dark:bg-yellow-900', 'ring-2', 'ring-yellow-400', 'transition-all', 'duration-300', 'rounded');
setTimeout(() => {
element.classList.remove('bg-yellow-200', 'dark:bg-yellow-900', 'ring-2', 'ring-yellow-400');
}, 3000);
};
// Highlight matching text
const highlightMatch = (text: string, query: string) => {
if (!query.trim()) return text;
const regex = new RegExp(`(${query})`, 'gi');
const parts = text.split(regex);
return parts.map((part, index) =>
regex.test(part) ? (
<span key={index} className="bg-yellow-200 dark:bg-yellow-900 font-semibold">{part}</span>
) : (
<span key={index}>{part}</span>
)
);
};
return {
searchQuery,
setSearchQuery,
searchResults,
showSearchResults,
setShowSearchResults,
handleSearch,
handleSearchKeyDown,
handleClearSearch,
handleNavigateToResult,
highlightMatch,
};
};

View File

@@ -0,0 +1,5 @@
// Document edit UI components
// export * from './DocumentEditor';
export {};

View File

@@ -0,0 +1,3 @@
export * from './model';
export * from './ui';

Some files were not shown because too many files have changed in this diff Show More