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:
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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user