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:
148
.dockerignore
Normal file
148
.dockerignore
Normal 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
73
.github/workflows/build.yml
vendored
Normal 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
45
.github/workflows/ci.yml
vendored
Normal 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
56
.gitignore
vendored
Normal 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
62
Dockerfile
Normal 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"]
|
||||
61
nextjs/app/api/auth/login/route.ts
Normal file
61
nextjs/app/api/auth/login/route.ts
Normal 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 }
|
||||
)
|
||||
}
|
||||
}
|
||||
22
nextjs/app/api/auth/logout/route.ts
Normal file
22
nextjs/app/api/auth/logout/route.ts
Normal 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 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
54
nextjs/app/api/auth/me/route.ts
Normal file
54
nextjs/app/api/auth/me/route.ts
Normal 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 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
76
nextjs/app/api/auth/register/route.ts
Normal file
76
nextjs/app/api/auth/register/route.ts
Normal 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 }
|
||||
)
|
||||
}
|
||||
}
|
||||
49
nextjs/app/api/documents/[id]/public/route.ts
Normal file
49
nextjs/app/api/documents/[id]/public/route.ts
Normal 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 }
|
||||
)
|
||||
}
|
||||
}
|
||||
202
nextjs/app/api/documents/[id]/route.ts
Normal file
202
nextjs/app/api/documents/[id]/route.ts
Normal 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 }
|
||||
)
|
||||
}
|
||||
})
|
||||
84
nextjs/app/api/documents/route.ts
Normal file
84
nextjs/app/api/documents/route.ts
Normal 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 }
|
||||
)
|
||||
}
|
||||
})
|
||||
46
nextjs/app/api/download-url/route.ts
Normal file
46
nextjs/app/api/download-url/route.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
264
nextjs/app/api/folders/[id]/route.ts
Normal file
264
nextjs/app/api/folders/[id]/route.ts
Normal 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 }
|
||||
)
|
||||
}
|
||||
})
|
||||
154
nextjs/app/api/folders/route.ts
Normal file
154
nextjs/app/api/folders/route.ts
Normal 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 }
|
||||
)
|
||||
}
|
||||
})
|
||||
27
nextjs/app/api/health/route.ts
Normal file
27
nextjs/app/api/health/route.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
22
nextjs/app/api/metadata/route.ts
Normal file
22
nextjs/app/api/metadata/route.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
105
nextjs/app/api/templates/route.ts
Normal file
105
nextjs/app/api/templates/route.ts
Normal 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);
|
||||
57
nextjs/app/api/upload-url/route.ts
Normal file
57
nextjs/app/api/upload-url/route.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
177
nextjs/app/api/upload/route.ts
Normal file
177
nextjs/app/api/upload/route.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
296
nextjs/app/documents/[id]/page.tsx
Normal file
296
nextjs/app/documents/[id]/page.tsx
Normal 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;
|
||||
45
nextjs/app/documents/layout.tsx
Normal file
45
nextjs/app/documents/layout.tsx
Normal 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;
|
||||
202
nextjs/app/documents/page.tsx
Normal file
202
nextjs/app/documents/page.tsx
Normal 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;
|
||||
12
nextjs/app/documents/todos/page.tsx
Normal file
12
nextjs/app/documents/todos/page.tsx
Normal 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
BIN
nextjs/app/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 26 KiB |
321
nextjs/app/globals.css
Normal file
321
nextjs/app/globals.css
Normal 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;
|
||||
}
|
||||
12
nextjs/app/home/layout.tsx
Normal file
12
nextjs/app/home/layout.tsx
Normal 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
17
nextjs/app/home/page.tsx
Normal 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;
|
||||
161
nextjs/app/home/share/[id]/page.tsx
Normal file
161
nextjs/app/home/share/[id]/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
116
nextjs/app/home/signIn/page.tsx
Normal file
116
nextjs/app/home/signIn/page.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
167
nextjs/app/home/signUp/page.tsx
Normal file
167
nextjs/app/home/signUp/page.tsx
Normal 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
62
nextjs/app/layout.tsx
Normal 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
17
nextjs/components.json
Normal 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
71
nextjs/middleware.ts
Normal 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
48
nextjs/next.config.mjs
Normal 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
6346
nextjs/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
66
nextjs/package.json
Normal file
66
nextjs/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
9
nextjs/postcss.config.mjs
Normal file
9
nextjs/postcss.config.mjs
Normal 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
131
nextjs/prisma/schema.prisma
Normal 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
BIN
nextjs/public/documents.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 54 KiB |
BIN
nextjs/public/documents_dark.png
Normal file
BIN
nextjs/public/documents_dark.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 60 KiB |
BIN
nextjs/public/notionIcon.png
Normal file
BIN
nextjs/public/notionIcon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 139 KiB |
BIN
nextjs/public/notionIcond.png
Normal file
BIN
nextjs/public/notionIcond.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 11 KiB |
3
nextjs/src/app/config/index.ts
Normal file
3
nextjs/src/app/config/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
// App configuration exports
|
||||
export * from './metadata'
|
||||
|
||||
21
nextjs/src/app/config/metadata.ts
Normal file
21
nextjs/src/app/config/metadata.ts
Normal 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
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
321
nextjs/src/app/globals.css
Normal 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;
|
||||
}
|
||||
72
nextjs/src/app/providers/auth-provider.tsx
Normal file
72
nextjs/src/app/providers/auth-provider.tsx
Normal 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
|
||||
}
|
||||
|
||||
10
nextjs/src/app/providers/theme-provider.tsx
Normal file
10
nextjs/src/app/providers/theme-provider.tsx
Normal 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>
|
||||
}
|
||||
|
||||
67
nextjs/src/entities/document/api/index.ts
Normal file
67
nextjs/src/entities/document/api/index.ts
Normal 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;
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
4
nextjs/src/entities/document/index.ts
Normal file
4
nextjs/src/entities/document/index.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export * from './api';
|
||||
export * from './model';
|
||||
export * from './ui';
|
||||
|
||||
4
nextjs/src/entities/document/model/index.ts
Normal file
4
nextjs/src/entities/document/model/index.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export * from './types';
|
||||
export * from './store';
|
||||
export * from './validation';
|
||||
|
||||
36
nextjs/src/entities/document/model/store.ts
Normal file
36
nextjs/src/entities/document/model/store.ts
Normal 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 }),
|
||||
}));
|
||||
|
||||
70
nextjs/src/entities/document/model/types.ts
Normal file
70
nextjs/src/entities/document/model/types.ts
Normal 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;
|
||||
}
|
||||
|
||||
21
nextjs/src/entities/document/model/validation.ts
Normal file
21
nextjs/src/entities/document/model/validation.ts
Normal 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>;
|
||||
|
||||
45
nextjs/src/entities/document/ui/DocumentItem.tsx
Normal file
45
nextjs/src/entities/document/ui/DocumentItem.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
|
||||
2
nextjs/src/entities/document/ui/index.ts
Normal file
2
nextjs/src/entities/document/ui/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export * from './DocumentItem';
|
||||
|
||||
57
nextjs/src/entities/folder/api/index.ts
Normal file
57
nextjs/src/entities/folder/api/index.ts
Normal 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;
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
4
nextjs/src/entities/folder/index.ts
Normal file
4
nextjs/src/entities/folder/index.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export * from './api';
|
||||
export * from './model';
|
||||
export * from './ui';
|
||||
|
||||
4
nextjs/src/entities/folder/model/index.ts
Normal file
4
nextjs/src/entities/folder/model/index.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export * from './types';
|
||||
export * from './store';
|
||||
export * from './validation';
|
||||
|
||||
36
nextjs/src/entities/folder/model/store.ts
Normal file
36
nextjs/src/entities/folder/model/store.ts
Normal 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 }),
|
||||
}));
|
||||
|
||||
44
nextjs/src/entities/folder/model/types.ts
Normal file
44
nextjs/src/entities/folder/model/types.ts
Normal 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;
|
||||
}
|
||||
|
||||
13
nextjs/src/entities/folder/model/validation.ts
Normal file
13
nextjs/src/entities/folder/model/validation.ts
Normal 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>;
|
||||
|
||||
40
nextjs/src/entities/folder/ui/FolderItem.tsx
Normal file
40
nextjs/src/entities/folder/ui/FolderItem.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
|
||||
2
nextjs/src/entities/folder/ui/index.ts
Normal file
2
nextjs/src/entities/folder/ui/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export * from './FolderItem';
|
||||
|
||||
6
nextjs/src/entities/index.ts
Normal file
6
nextjs/src/entities/index.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
// Export all entities
|
||||
export * from './document';
|
||||
export * from './folder';
|
||||
export * from './user';
|
||||
export * from './template';
|
||||
|
||||
56
nextjs/src/entities/template/api/index.ts
Normal file
56
nextjs/src/entities/template/api/index.ts
Normal 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;
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
4
nextjs/src/entities/template/index.ts
Normal file
4
nextjs/src/entities/template/index.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export * from './api';
|
||||
export * from './model';
|
||||
export * from './ui';
|
||||
|
||||
4
nextjs/src/entities/template/model/index.ts
Normal file
4
nextjs/src/entities/template/model/index.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export * from './types';
|
||||
export * from './store';
|
||||
export * from './validation';
|
||||
|
||||
36
nextjs/src/entities/template/model/store.ts
Normal file
36
nextjs/src/entities/template/model/store.ts
Normal 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 }),
|
||||
}));
|
||||
|
||||
49
nextjs/src/entities/template/model/types.ts
Normal file
49
nextjs/src/entities/template/model/types.ts
Normal 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;
|
||||
}
|
||||
|
||||
14
nextjs/src/entities/template/model/validation.ts
Normal file
14
nextjs/src/entities/template/model/validation.ts
Normal 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>;
|
||||
|
||||
6
nextjs/src/entities/template/ui/index.ts
Normal file
6
nextjs/src/entities/template/ui/index.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
// Template UI components would be defined here
|
||||
// export * from './TemplateCard';
|
||||
// export * from './TemplateSelector';
|
||||
|
||||
export {};
|
||||
|
||||
170
nextjs/src/entities/todo/api/index.ts
Normal file
170
nextjs/src/entities/todo/api/index.ts
Normal 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,
|
||||
}
|
||||
|
||||
3
nextjs/src/entities/todo/index.ts
Normal file
3
nextjs/src/entities/todo/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export * from './model'
|
||||
export * from './api'
|
||||
|
||||
3
nextjs/src/entities/todo/model/index.ts
Normal file
3
nextjs/src/entities/todo/model/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export * from './types'
|
||||
export * from './store'
|
||||
|
||||
133
nextjs/src/entities/todo/model/store.ts
Normal file
133
nextjs/src/entities/todo/model/store.ts
Normal 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
|
||||
})
|
||||
}
|
||||
|
||||
42
nextjs/src/entities/todo/model/types.ts
Normal file
42
nextjs/src/entities/todo/model/types.ts
Normal 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
|
||||
|
||||
132
nextjs/src/entities/todo/ui/TodoItem.tsx
Normal file
132
nextjs/src/entities/todo/ui/TodoItem.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
||||
53
nextjs/src/entities/todo/ui/TodoList.tsx
Normal file
53
nextjs/src/entities/todo/ui/TodoList.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
||||
3
nextjs/src/entities/todo/ui/index.ts
Normal file
3
nextjs/src/entities/todo/ui/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export * from './TodoItem'
|
||||
export * from './TodoList'
|
||||
|
||||
53
nextjs/src/entities/user/api/index.ts
Normal file
53
nextjs/src/entities/user/api/index.ts
Normal 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;
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
4
nextjs/src/entities/user/index.ts
Normal file
4
nextjs/src/entities/user/index.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export * from './api';
|
||||
export * from './model';
|
||||
export * from './ui';
|
||||
|
||||
4
nextjs/src/entities/user/model/index.ts
Normal file
4
nextjs/src/entities/user/model/index.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export * from './types';
|
||||
export * from './store';
|
||||
export * from './validation';
|
||||
|
||||
13
nextjs/src/entities/user/model/store.ts
Normal file
13
nextjs/src/entities/user/model/store.ts
Normal 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 }),
|
||||
}));
|
||||
|
||||
27
nextjs/src/entities/user/model/types.ts
Normal file
27
nextjs/src/entities/user/model/types.ts
Normal 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;
|
||||
}
|
||||
|
||||
11
nextjs/src/entities/user/model/validation.ts
Normal file
11
nextjs/src/entities/user/model/validation.ts
Normal 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>;
|
||||
|
||||
6
nextjs/src/entities/user/ui/index.ts
Normal file
6
nextjs/src/entities/user/ui/index.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
// User UI components would be defined here
|
||||
// export * from './UserAvatar';
|
||||
// export * from './UserProfile';
|
||||
|
||||
export {};
|
||||
|
||||
3
nextjs/src/features/document-edit/index.ts
Normal file
3
nextjs/src/features/document-edit/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export * from './model';
|
||||
export * from './ui';
|
||||
|
||||
9
nextjs/src/features/document-edit/model/index.ts
Normal file
9
nextjs/src/features/document-edit/model/index.ts
Normal 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';
|
||||
|
||||
70
nextjs/src/features/document-edit/model/store.ts
Normal file
70
nextjs/src/features/document-edit/model/store.ts
Normal 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,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
|
||||
204
nextjs/src/features/document-edit/model/use-sidebar-search.tsx
Normal file
204
nextjs/src/features/document-edit/model/use-sidebar-search.tsx
Normal 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,
|
||||
};
|
||||
};
|
||||
|
||||
5
nextjs/src/features/document-edit/ui/index.ts
Normal file
5
nextjs/src/features/document-edit/ui/index.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
// Document edit UI components
|
||||
// export * from './DocumentEditor';
|
||||
|
||||
export {};
|
||||
|
||||
3
nextjs/src/features/document-management/index.ts
Normal file
3
nextjs/src/features/document-management/index.ts
Normal 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
Reference in New Issue
Block a user