REFACTOR(repo): simplify project structure

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

BIN
nextjs/app/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

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

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

View File

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

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

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

View File

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

View File

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

View File

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

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

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