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