Major changes
This commit is contained in:
@@ -39,3 +39,5 @@ yarn-error.log*
|
|||||||
# typescript
|
# typescript
|
||||||
*.tsbuildinfo
|
*.tsbuildinfo
|
||||||
next-env.d.ts
|
next-env.d.ts
|
||||||
|
|
||||||
|
/lib/generated/prisma
|
||||||
|
|||||||
@@ -1,36 +0,0 @@
|
|||||||
This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app).
|
|
||||||
|
|
||||||
## Getting Started
|
|
||||||
|
|
||||||
First, run the development server:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
npm run dev
|
|
||||||
# or
|
|
||||||
yarn dev
|
|
||||||
# or
|
|
||||||
pnpm dev
|
|
||||||
# or
|
|
||||||
bun dev
|
|
||||||
```
|
|
||||||
|
|
||||||
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
|
|
||||||
|
|
||||||
You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
|
|
||||||
|
|
||||||
This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel.
|
|
||||||
|
|
||||||
## Learn More
|
|
||||||
|
|
||||||
To learn more about Next.js, take a look at the following resources:
|
|
||||||
|
|
||||||
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
|
|
||||||
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
|
|
||||||
|
|
||||||
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome!
|
|
||||||
|
|
||||||
## Deploy on Vercel
|
|
||||||
|
|
||||||
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
|
|
||||||
|
|
||||||
Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.
|
|
||||||
@@ -0,0 +1,252 @@
|
|||||||
|
import { NextRequest } from 'next/server';
|
||||||
|
import { prisma } from '@/lib/db';
|
||||||
|
|
||||||
|
interface WikipediaSummary {
|
||||||
|
title: string;
|
||||||
|
extract: string;
|
||||||
|
thumbnail?: { source: string };
|
||||||
|
originalimage?: { source: string };
|
||||||
|
}
|
||||||
|
|
||||||
|
interface WikiParseResult {
|
||||||
|
parse: {
|
||||||
|
title: string;
|
||||||
|
text: { '*': string };
|
||||||
|
images: string[];
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getWikipediaSummary(title: string): Promise<WikipediaSummary | null> {
|
||||||
|
try {
|
||||||
|
const apiTitle = title.replace(/%20/g, '_');
|
||||||
|
const response = await fetch(
|
||||||
|
`https://en.wikipedia.org/api/rest_v1/page/summary/${apiTitle}`,
|
||||||
|
{
|
||||||
|
headers: { 'User-Agent': 'WikiPlus/1.0' },
|
||||||
|
}
|
||||||
|
);
|
||||||
|
if (!response.ok) return null;
|
||||||
|
return await response.json();
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getWikipediaContent(title: string): Promise<{ text: string; images: string[] } | null> {
|
||||||
|
try {
|
||||||
|
const apiTitle = title.replace(/%20/g, '_');
|
||||||
|
const response = await fetch(
|
||||||
|
`https://en.wikipedia.org/w/api.php?action=parse&page=${encodeURIComponent(apiTitle)}&format=json&prop=text|images&disableeditsection=true&redirects=true`,
|
||||||
|
{
|
||||||
|
headers: { 'User-Agent': 'WikiPlus/1.0' },
|
||||||
|
}
|
||||||
|
);
|
||||||
|
if (!response.ok) return null;
|
||||||
|
const data: WikiParseResult = await response.json();
|
||||||
|
if (!data.parse) return null;
|
||||||
|
|
||||||
|
// Extract plain text from HTML
|
||||||
|
const html = data.parse.text?.['*'] || '';
|
||||||
|
const plainText = html
|
||||||
|
.replace(/<style[^>]*>[\s\S]*?<\/style>/gi, '')
|
||||||
|
.replace(/<script[^>]*>[\s\S]*?<\/script>/gi, '')
|
||||||
|
.replace(/<[^>]+>/g, ' ')
|
||||||
|
.replace(/ /g, ' ')
|
||||||
|
.replace(/&/g, '&')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
.replace(/"/g, '"')
|
||||||
|
.replace(/'/g, "'")
|
||||||
|
.replace(/\s+/g, ' ')
|
||||||
|
.trim();
|
||||||
|
|
||||||
|
// Get image URLs
|
||||||
|
const images = (data.parse.images || [])
|
||||||
|
.filter((img: string) => !img.includes('icon') && !img.includes('logo') && !img.includes('Commons-logo'))
|
||||||
|
.slice(0, 5)
|
||||||
|
.map((img: string) => `https://en.wikipedia.org/wiki/Special:FilePath/${encodeURIComponent(img)}`);
|
||||||
|
|
||||||
|
return { text: plainText.slice(0, 15000), images };
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function GET(request: NextRequest) {
|
||||||
|
const searchParams = request.nextUrl.searchParams;
|
||||||
|
const title = searchParams.get('title');
|
||||||
|
|
||||||
|
if (!title) {
|
||||||
|
return new Response('Missing title parameter', { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Normalize the slug for database lookup
|
||||||
|
const slug = title.replace(/%20/g, '_').replace(/ /g, '_');
|
||||||
|
|
||||||
|
// Check if article exists in database
|
||||||
|
const existingArticle = await prisma.article.findUnique({
|
||||||
|
where: { slug },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (existingArticle) {
|
||||||
|
// Return cached article wrapped in <article> tags
|
||||||
|
const cachedContent = `<article>${existingArticle.content}</article>`;
|
||||||
|
return new Response(cachedContent, {
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'text/plain; charset=utf-8',
|
||||||
|
'X-Cache': 'HIT',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const apiKey = process.env.OPENROUTER_API_KEY;
|
||||||
|
if (!apiKey) {
|
||||||
|
return new Response('OpenRouter API key not configured', { status: 500 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch Wikipedia data
|
||||||
|
const [summary, content] = await Promise.all([
|
||||||
|
getWikipediaSummary(title),
|
||||||
|
getWikipediaContent(title),
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (!summary || !content) {
|
||||||
|
return new Response('Article not found', { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const imageUrl = summary.thumbnail?.source || summary.originalimage?.source;
|
||||||
|
const imageContext = imageUrl
|
||||||
|
? `\n\nMain image available: ${imageUrl}\nAdditional images: ${content.images.join(', ')}`
|
||||||
|
: content.images.length > 0
|
||||||
|
? `\n\nImages available: ${content.images.join(', ')}`
|
||||||
|
: '';
|
||||||
|
|
||||||
|
const systemPrompt = `You are an expert writer creating comprehensive, in-depth articles for an AI-powered Wikipedia alternative. Your goal is to transform encyclopedia content into rich, detailed, and engaging prose that thoroughly covers the topic.
|
||||||
|
|
||||||
|
Guidelines:
|
||||||
|
- Write a COMPREHENSIVE and DETAILED article - aim for depth and thoroughness
|
||||||
|
- Cover ALL major aspects of the topic: history, significance, key details, related concepts, and impact
|
||||||
|
- Use markdown formatting extensively:
|
||||||
|
- Use ## for main sections and ### for subsections
|
||||||
|
- Use **bold** for key terms and *italic* for emphasis
|
||||||
|
- Use bullet lists and numbered lists where appropriate
|
||||||
|
- Use > blockquotes for notable quotes or key facts
|
||||||
|
- Include the main image at the top using markdown: 
|
||||||
|
- Structure the article with multiple well-developed sections (5-8 sections minimum)
|
||||||
|
- Each section should have multiple paragraphs with detailed explanations
|
||||||
|
- Add context that helps readers understand why this topic matters
|
||||||
|
- Include interesting facts, historical context, and connections to broader themes
|
||||||
|
- Maintain factual accuracy - expand on the source material but don't invent facts
|
||||||
|
- Write at least 1000-1500 words for a thorough treatment of the topic
|
||||||
|
- Output ONLY the article content wrapped in <article></article> tags
|
||||||
|
- Do not include any text outside the <article> tags`;
|
||||||
|
|
||||||
|
const userPrompt = `Write a comprehensive, detailed article about "${summary.title}" based on this Wikipedia content:
|
||||||
|
|
||||||
|
Summary: ${summary.extract}
|
||||||
|
|
||||||
|
Full content: ${content.text}${imageContext}
|
||||||
|
|
||||||
|
Requirements:
|
||||||
|
- Write a THOROUGH article with 5-8 well-developed sections minimum
|
||||||
|
- Each section should have multiple detailed paragraphs
|
||||||
|
- Cover history, significance, key facts, and broader context
|
||||||
|
- Use rich markdown formatting throughout
|
||||||
|
- Aim for 1000-1500+ words total
|
||||||
|
- Output in markdown wrapped in <article></article> tags`;
|
||||||
|
|
||||||
|
// Call OpenRouter API with streaming
|
||||||
|
const openRouterResponse = await fetch('https://openrouter.ai/api/v1/chat/completions', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Authorization': `Bearer ${apiKey}`,
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
model: 'minimax/minimax-m2.1',
|
||||||
|
messages: [
|
||||||
|
{ role: 'system', content: systemPrompt },
|
||||||
|
{ role: 'user', content: userPrompt },
|
||||||
|
],
|
||||||
|
stream: true,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!openRouterResponse.ok) {
|
||||||
|
const error = await openRouterResponse.text();
|
||||||
|
console.error('OpenRouter error:', error);
|
||||||
|
return new Response('Failed to generate article', { status: 500 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Transform the OpenRouter SSE stream to extract content and save to DB
|
||||||
|
const encoder = new TextEncoder();
|
||||||
|
const decoder = new TextDecoder();
|
||||||
|
let fullContent = '';
|
||||||
|
|
||||||
|
const transformStream = new TransformStream({
|
||||||
|
async transform(chunk, controller) {
|
||||||
|
const text = decoder.decode(chunk);
|
||||||
|
const lines = text.split('\n');
|
||||||
|
|
||||||
|
for (const line of lines) {
|
||||||
|
if (line.startsWith('data: ')) {
|
||||||
|
const data = line.slice(6);
|
||||||
|
if (data === '[DONE]') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(data);
|
||||||
|
const contentChunk = parsed.choices?.[0]?.delta?.content;
|
||||||
|
if (contentChunk) {
|
||||||
|
fullContent += contentChunk;
|
||||||
|
controller.enqueue(encoder.encode(contentChunk));
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Skip invalid JSON
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async flush() {
|
||||||
|
// Extract content between <article> tags and save to database
|
||||||
|
const articleMatch = fullContent.match(/<article>([\s\S]*?)<\/article>/);
|
||||||
|
const articleContent = articleMatch ? articleMatch[1].trim() : fullContent;
|
||||||
|
|
||||||
|
if (articleContent) {
|
||||||
|
try {
|
||||||
|
const newArticle = await prisma.article.create({
|
||||||
|
data: {
|
||||||
|
slug,
|
||||||
|
title: summary.title,
|
||||||
|
content: articleContent,
|
||||||
|
imageUrl: imageUrl || null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create initial history entry
|
||||||
|
await prisma.articleHistory.create({
|
||||||
|
data: {
|
||||||
|
articleId: newArticle.id,
|
||||||
|
oldContent: '',
|
||||||
|
newContent: articleContent,
|
||||||
|
reason: 'Initial article',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to save article to database:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const stream = openRouterResponse.body?.pipeThrough(transformStream);
|
||||||
|
|
||||||
|
return new Response(stream, {
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'text/plain; charset=utf-8',
|
||||||
|
'Transfer-Encoding': 'chunked',
|
||||||
|
'Cache-Control': 'no-cache',
|
||||||
|
'X-Cache': 'MISS',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { prisma } from '@/lib/db';
|
||||||
|
|
||||||
|
export async function GET(
|
||||||
|
request: NextRequest,
|
||||||
|
{ params }: { params: Promise<{ slug: string }> }
|
||||||
|
) {
|
||||||
|
const { slug } = await params;
|
||||||
|
|
||||||
|
// Fetch article to get its ID
|
||||||
|
const article = await prisma.article.findUnique({
|
||||||
|
where: { slug },
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!article) {
|
||||||
|
return NextResponse.json({ error: 'Article not found' }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch history entries
|
||||||
|
const history = await prisma.articleHistory.findMany({
|
||||||
|
where: { articleId: article.id },
|
||||||
|
orderBy: { createdAt: 'desc' },
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
oldContent: true,
|
||||||
|
newContent: true,
|
||||||
|
reason: true,
|
||||||
|
createdAt: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json(history);
|
||||||
|
}
|
||||||
@@ -0,0 +1,147 @@
|
|||||||
|
import { NextRequest } from 'next/server';
|
||||||
|
import { prisma } from '@/lib/db';
|
||||||
|
import * as Diff from 'diff';
|
||||||
|
|
||||||
|
function generateDiffText(oldContent: string, newContent: string): string {
|
||||||
|
const changes = Diff.diffLines(oldContent, newContent);
|
||||||
|
const diffLines: string[] = [];
|
||||||
|
|
||||||
|
for (const change of changes) {
|
||||||
|
const lines = change.value.split('\n').filter(line => line !== '');
|
||||||
|
for (const line of lines) {
|
||||||
|
if (change.added) {
|
||||||
|
diffLines.push(`+ ${line}`);
|
||||||
|
} else if (change.removed) {
|
||||||
|
diffLines.push(`- ${line}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return diffLines.join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(
|
||||||
|
request: NextRequest,
|
||||||
|
{ params }: { params: Promise<{ slug: string }> }
|
||||||
|
) {
|
||||||
|
const { slug } = await params;
|
||||||
|
|
||||||
|
const body = await request.json();
|
||||||
|
const { newContent } = body;
|
||||||
|
|
||||||
|
if (!newContent || typeof newContent !== 'string') {
|
||||||
|
return new Response('Missing newContent', { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch current article
|
||||||
|
const article = await prisma.article.findUnique({
|
||||||
|
where: { slug },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!article) {
|
||||||
|
return new Response('Article not found', { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const apiKey = process.env.OPENROUTER_API_KEY;
|
||||||
|
if (!apiKey) {
|
||||||
|
return new Response('OpenRouter API key not configured', { status: 500 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate diff between old and new content
|
||||||
|
const diffText = generateDiffText(article.content, newContent);
|
||||||
|
|
||||||
|
const systemPrompt = `You are an article edit reviewer. Your job is to evaluate proposed changes (diffs) to articles.
|
||||||
|
|
||||||
|
You will be given:
|
||||||
|
1. The current article content (assume this is factually correct and the source of truth)
|
||||||
|
2. A diff showing the proposed changes (lines starting with "-" are removed, lines starting with "+" are added)
|
||||||
|
|
||||||
|
Evaluate ONLY the changes based on:
|
||||||
|
1. **Consistency** - Do the edits contradict or conflict with the existing article content?
|
||||||
|
2. **Quality** - Is the new/modified text well-written and grammatically correct?
|
||||||
|
3. **Relevance** - Are the changes relevant to the article topic?
|
||||||
|
4. **Appropriateness** - Is the content appropriate (no spam, vandalism, offensive content)?
|
||||||
|
|
||||||
|
Do NOT fact-check against external knowledge. Assume the original article is correct and evaluate whether the edits are consistent with it.
|
||||||
|
|
||||||
|
After your evaluation, you MUST output your decision in this exact format:
|
||||||
|
<action approve="true" reason="Your brief reason for approval" />
|
||||||
|
OR
|
||||||
|
<action approve="false" reason="Your brief reason for rejection" />
|
||||||
|
|
||||||
|
Be constructive in your feedback. If rejecting, explain what could be improved.`;
|
||||||
|
|
||||||
|
const userPrompt = `Please review the following article edit:
|
||||||
|
|
||||||
|
**Current Article:**
|
||||||
|
${article.content.slice(0, 6000)}
|
||||||
|
|
||||||
|
**Proposed Changes (diff):**
|
||||||
|
\`\`\`diff
|
||||||
|
${diffText}
|
||||||
|
\`\`\`
|
||||||
|
|
||||||
|
Evaluate only the changes shown in the diff and provide your decision.`;
|
||||||
|
|
||||||
|
// Call OpenRouter API with streaming
|
||||||
|
const openRouterResponse = await fetch('https://openrouter.ai/api/v1/chat/completions', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Authorization': `Bearer ${apiKey}`,
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
model: 'openai/gpt-oss-120b',
|
||||||
|
messages: [
|
||||||
|
{ role: 'system', content: systemPrompt },
|
||||||
|
{ role: 'user', content: userPrompt },
|
||||||
|
],
|
||||||
|
stream: true,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!openRouterResponse.ok) {
|
||||||
|
const error = await openRouterResponse.text();
|
||||||
|
console.error('OpenRouter error:', error);
|
||||||
|
return new Response('Failed to review article', { status: 500 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Transform the OpenRouter SSE stream
|
||||||
|
const encoder = new TextEncoder();
|
||||||
|
const decoder = new TextDecoder();
|
||||||
|
|
||||||
|
const transformStream = new TransformStream({
|
||||||
|
async transform(chunk, controller) {
|
||||||
|
const text = decoder.decode(chunk);
|
||||||
|
const lines = text.split('\n');
|
||||||
|
|
||||||
|
for (const line of lines) {
|
||||||
|
if (line.startsWith('data: ')) {
|
||||||
|
const data = line.slice(6);
|
||||||
|
if (data === '[DONE]') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(data);
|
||||||
|
const contentChunk = parsed.choices?.[0]?.delta?.content;
|
||||||
|
if (contentChunk) {
|
||||||
|
controller.enqueue(encoder.encode(contentChunk));
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Skip invalid JSON
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const stream = openRouterResponse.body?.pipeThrough(transformStream);
|
||||||
|
|
||||||
|
return new Response(stream, {
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'text/plain; charset=utf-8',
|
||||||
|
'Transfer-Encoding': 'chunked',
|
||||||
|
'Cache-Control': 'no-cache',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { prisma } from '@/lib/db';
|
||||||
|
|
||||||
|
export async function POST(
|
||||||
|
request: NextRequest,
|
||||||
|
{ params }: { params: Promise<{ slug: string }> }
|
||||||
|
) {
|
||||||
|
const { slug } = await params;
|
||||||
|
|
||||||
|
const body = await request.json();
|
||||||
|
const { newContent, reason } = body;
|
||||||
|
|
||||||
|
if (!newContent || typeof newContent !== 'string') {
|
||||||
|
return NextResponse.json({ error: 'Missing newContent' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!reason || typeof reason !== 'string') {
|
||||||
|
return NextResponse.json({ error: 'Missing reason' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch current article
|
||||||
|
const article = await prisma.article.findUnique({
|
||||||
|
where: { slug },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!article) {
|
||||||
|
return NextResponse.json({ error: 'Article not found' }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Transaction: create history entry and update article
|
||||||
|
const result = await prisma.$transaction(async (tx) => {
|
||||||
|
// Create history entry
|
||||||
|
const historyEntry = await tx.articleHistory.create({
|
||||||
|
data: {
|
||||||
|
articleId: article.id,
|
||||||
|
oldContent: article.content,
|
||||||
|
newContent: newContent,
|
||||||
|
reason: reason,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Update article content
|
||||||
|
await tx.article.update({
|
||||||
|
where: { id: article.id },
|
||||||
|
data: {
|
||||||
|
content: newContent,
|
||||||
|
updatedAt: new Date(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return historyEntry;
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
historyId: result.id,
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
import type { WikipediaTopic } from '@/lib/types';
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
try {
|
||||||
|
const response = await fetch(
|
||||||
|
'https://en.wikipedia.org/api/rest_v1/page/random/summary',
|
||||||
|
{
|
||||||
|
headers: {
|
||||||
|
'User-Agent': 'TeachMeSomethingNew/1.0',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('Failed to fetch from Wikipedia API');
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
const topic: WikipediaTopic = {
|
||||||
|
title: data.title,
|
||||||
|
extract: data.extract,
|
||||||
|
imageUrl: data.thumbnail?.source || data.originalimage?.source,
|
||||||
|
pageUrl: data.content_urls?.desktop?.page,
|
||||||
|
};
|
||||||
|
|
||||||
|
return NextResponse.json(topic);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching random topic:', error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Failed to fetch random topic' },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { prisma } from '@/lib/db';
|
||||||
|
|
||||||
|
export interface SearchResult {
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
thumbnail?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function GET(request: NextRequest) {
|
||||||
|
const searchParams = request.nextUrl.searchParams;
|
||||||
|
const query = searchParams.get('query');
|
||||||
|
|
||||||
|
if (!query) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Query parameter is required' },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Use the Wikipedia Action API for search
|
||||||
|
const response = await fetch(
|
||||||
|
`https://en.wikipedia.org/w/api.php?action=query&list=search&srsearch=${encodeURIComponent(query)}&srlimit=20&format=json&origin=*`,
|
||||||
|
{
|
||||||
|
headers: {
|
||||||
|
'User-Agent': 'WikiPlus/1.0',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('Failed to fetch from Wikipedia API');
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
// Get page IDs for thumbnail fetching
|
||||||
|
const searchResults = data.query?.search || [];
|
||||||
|
const pageIds = searchResults.map((r: { pageid: number }) => r.pageid).join('|');
|
||||||
|
|
||||||
|
// Fetch thumbnails for results
|
||||||
|
let thumbnails: Record<string, string> = {};
|
||||||
|
if (pageIds) {
|
||||||
|
const thumbResponse = await fetch(
|
||||||
|
`https://en.wikipedia.org/w/api.php?action=query&pageids=${pageIds}&prop=pageimages&pithumbsize=200&format=json&origin=*`,
|
||||||
|
{
|
||||||
|
headers: {
|
||||||
|
'User-Agent': 'WikiPlus/1.0',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
if (thumbResponse.ok) {
|
||||||
|
const thumbData = await thumbResponse.json();
|
||||||
|
const pages = thumbData.query?.pages || {};
|
||||||
|
for (const pageId in pages) {
|
||||||
|
if (pages[pageId].thumbnail) {
|
||||||
|
thumbnails[pageId] = pages[pageId].thumbnail.source;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const results: SearchResult[] = searchResults.map((item: {
|
||||||
|
pageid: number;
|
||||||
|
title: string;
|
||||||
|
snippet: string;
|
||||||
|
}) => ({
|
||||||
|
title: item.title,
|
||||||
|
description: item.snippet.replace(/<[^>]*>/g, ''), // Strip HTML tags from snippet
|
||||||
|
thumbnail: thumbnails[item.pageid.toString()],
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Log the search
|
||||||
|
try {
|
||||||
|
await prisma.search.create({
|
||||||
|
data: {
|
||||||
|
query,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error logging search:', error);
|
||||||
|
// Don't fail the search if logging fails
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({ results });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error searching Wikipedia:', error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Failed to search Wikipedia' },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
import { prisma } from '@/lib/db';
|
||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
try {
|
||||||
|
const today = new Date();
|
||||||
|
today.setHours(0, 0, 0, 0);
|
||||||
|
|
||||||
|
const [totalArticles, searchesToday, allArticles] = await Promise.all([
|
||||||
|
prisma.article.count(),
|
||||||
|
prisma.search.count({
|
||||||
|
where: {
|
||||||
|
createdAt: {
|
||||||
|
gte: today,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
prisma.article.findMany({
|
||||||
|
select: {
|
||||||
|
content: true,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const totalWords = allArticles.reduce((sum, article) => {
|
||||||
|
const words = article.content.split(/\s+/).filter(word => word.length > 0).length;
|
||||||
|
return sum + words;
|
||||||
|
}, 0);
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
totalArticles,
|
||||||
|
searchesToday,
|
||||||
|
totalWords,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching stats:', error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Failed to fetch statistics' },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,131 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { use, useState } from 'react';
|
||||||
|
import Link from 'next/link';
|
||||||
|
import { ArrowLeft, ExternalLink, Pencil, Clock } from 'lucide-react';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { PageTransition } from '@/components/page-transition';
|
||||||
|
import { ArticleStream } from '@/components/article-stream';
|
||||||
|
import { HistoryModal } from '@/components/history-modal';
|
||||||
|
|
||||||
|
export default function ArticlePage({
|
||||||
|
params,
|
||||||
|
}: {
|
||||||
|
params: Promise<{ title: string }>;
|
||||||
|
}) {
|
||||||
|
const { title } = use(params);
|
||||||
|
const decodedTitle = decodeURIComponent(title).replace(/_/g, ' ');
|
||||||
|
const wikipediaUrl = `https://en.wikipedia.org/wiki/${title}`;
|
||||||
|
const slug = title.replace(/%20/g, '_').replace(/ /g, '_');
|
||||||
|
|
||||||
|
const [isEditMode, setIsEditMode] = useState(false);
|
||||||
|
const [isHistoryOpen, setIsHistoryOpen] = useState(false);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main className="min-h-screen bg-background">
|
||||||
|
<header className="sticky top-0 z-10 bg-background/95 backdrop-blur border-b border-border">
|
||||||
|
<div className="max-w-5xl mx-auto px-4 lg:px-8 py-3 flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<Link
|
||||||
|
href="/"
|
||||||
|
className="text-muted-foreground hover:text-foreground transition-colors"
|
||||||
|
>
|
||||||
|
<ArrowLeft className="w-5 h-5" />
|
||||||
|
</Link>
|
||||||
|
<Link href="/" className="text-xl font-bold text-foreground">
|
||||||
|
Wiki<span className="text-blue-500">+</span>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="gap-2"
|
||||||
|
onClick={() => setIsHistoryOpen(true)}
|
||||||
|
title="View History"
|
||||||
|
>
|
||||||
|
<Clock className="w-4 h-4" />
|
||||||
|
<span className="hidden sm:inline">History</span>
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="gap-2"
|
||||||
|
onClick={() => setIsEditMode(!isEditMode)}
|
||||||
|
title={isEditMode ? 'Cancel Edit' : 'Edit Article'}
|
||||||
|
>
|
||||||
|
<Pencil className="w-4 h-4" />
|
||||||
|
<span className="hidden sm:inline">{isEditMode ? 'Cancel' : 'Edit'}</span>
|
||||||
|
</Button>
|
||||||
|
<a
|
||||||
|
href={wikipediaUrl}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="text-muted-foreground hover:text-foreground transition-colors p-2"
|
||||||
|
title="View on Wikipedia"
|
||||||
|
>
|
||||||
|
<ExternalLink className="w-4 h-4" />
|
||||||
|
</a>
|
||||||
|
<PageTransition href="/random">
|
||||||
|
<Button variant="ghost" size="sm" className="gap-2">
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
className="w-4 h-4"
|
||||||
|
>
|
||||||
|
<path d="M21 12a9 9 0 0 0-9-9 9.75 9.75 0 0 0-6.74 2.74L3 8" />
|
||||||
|
<path d="M3 3v5h5" />
|
||||||
|
<path d="M3 12a9 9 0 0 0 9 9 9.75 9.75 0 0 0 6.74-2.74L21 16" />
|
||||||
|
<path d="M16 16h5v5" />
|
||||||
|
</svg>
|
||||||
|
Random
|
||||||
|
</Button>
|
||||||
|
</PageTransition>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<article className="max-w-5xl mx-auto px-4 lg:px-8 py-8">
|
||||||
|
<h1 className="text-3xl sm:text-4xl font-bold text-foreground mb-8 leading-tight lg:pl-72">
|
||||||
|
{decodedTitle}
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
<ArticleStream
|
||||||
|
title={title}
|
||||||
|
slug={slug}
|
||||||
|
decodedTitle={decodedTitle}
|
||||||
|
isEditMode={isEditMode}
|
||||||
|
onEditModeChange={setIsEditMode}
|
||||||
|
/>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<footer className="border-t border-border mt-12">
|
||||||
|
<div className="max-w-5xl mx-auto px-4 lg:px-8 py-6 flex items-center justify-between lg:pl-72">
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
AI-generated from Wikipedia
|
||||||
|
</p>
|
||||||
|
<a
|
||||||
|
href={wikipediaUrl}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="text-sm text-blue-500 hover:underline inline-flex items-center gap-1"
|
||||||
|
>
|
||||||
|
View original
|
||||||
|
<ExternalLink className="w-3 h-3" />
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
|
||||||
|
<HistoryModal
|
||||||
|
slug={slug}
|
||||||
|
isOpen={isHistoryOpen}
|
||||||
|
onClose={() => setIsHistoryOpen(false)}
|
||||||
|
/>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
+1443
-12
File diff suppressed because it is too large
Load Diff
+7
-3
@@ -1,6 +1,10 @@
|
|||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
import { Geist, Geist_Mono } from "next/font/google";
|
import { Geist, Geist_Mono } from "next/font/google";
|
||||||
import "./globals.css";
|
import "./globals.css";
|
||||||
|
import { config } from '@fortawesome/fontawesome-svg-core';
|
||||||
|
import '@fortawesome/fontawesome-svg-core/styles.css';
|
||||||
|
|
||||||
|
config.autoAddCss = false;
|
||||||
|
|
||||||
const geistSans = Geist({
|
const geistSans = Geist({
|
||||||
variable: "--font-geist-sans",
|
variable: "--font-geist-sans",
|
||||||
@@ -13,8 +17,8 @@ const geistMono = Geist_Mono({
|
|||||||
});
|
});
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: "Create Next App",
|
title: "Wiki+",
|
||||||
description: "Generated by create next app",
|
description: "Discover random knowledge from Wikipedia",
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function RootLayout({
|
export default function RootLayout({
|
||||||
@@ -23,7 +27,7 @@ export default function RootLayout({
|
|||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
}>) {
|
}>) {
|
||||||
return (
|
return (
|
||||||
<html lang="en">
|
<html lang="en" className="dark">
|
||||||
<body
|
<body
|
||||||
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
|
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
|
||||||
>
|
>
|
||||||
|
|||||||
+57
-57
@@ -1,65 +1,65 @@
|
|||||||
import Image from "next/image";
|
'use client';
|
||||||
|
|
||||||
|
import { useState, FormEvent } from 'react';
|
||||||
|
import { useRouter } from 'next/navigation';
|
||||||
|
import { PageTransition } from '@/components/page-transition';
|
||||||
|
import { Search, Shuffle } from 'lucide-react';
|
||||||
|
|
||||||
export default function Home() {
|
export default function Home() {
|
||||||
|
const [query, setQuery] = useState('');
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
const handleSearch = (e: FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (query.trim()) {
|
||||||
|
router.push(`/search?query=${encodeURIComponent(query.trim())}`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex min-h-screen items-center justify-center bg-zinc-50 font-sans dark:bg-black">
|
<main className="min-h-screen flex flex-col items-center justify-center px-6">
|
||||||
<main className="flex min-h-screen w-full max-w-3xl flex-col items-center justify-between py-32 px-16 bg-white dark:bg-black sm:items-start">
|
<div className="w-full max-w-xl text-center">
|
||||||
<Image
|
<h1 className="text-5xl sm:text-6xl font-semibold tracking-tight text-foreground mb-3">
|
||||||
className="dark:invert"
|
Wiki<span className="text-neutral-400">+</span>
|
||||||
src="/next.svg"
|
</h1>
|
||||||
alt="Next.js logo"
|
|
||||||
width={100}
|
<p className="text-muted-foreground mb-10">
|
||||||
height={20}
|
Search the encyclopedia
|
||||||
priority
|
</p>
|
||||||
/>
|
|
||||||
<div className="flex flex-col items-center gap-6 text-center sm:items-start sm:text-left">
|
<form onSubmit={handleSearch} className="w-full mb-6">
|
||||||
<h1 className="max-w-xs text-3xl font-semibold leading-10 tracking-tight text-black dark:text-zinc-50">
|
<div className="flex items-center w-full px-4 py-3 rounded-xl border border-border bg-card focus-within:border-neutral-400 transition-colors">
|
||||||
To get started, edit the page.tsx file.
|
<Search className="w-5 h-5 text-muted-foreground mr-3 flex-shrink-0" />
|
||||||
</h1>
|
<input
|
||||||
<p className="max-w-md text-lg leading-8 text-zinc-600 dark:text-zinc-400">
|
type="text"
|
||||||
Looking for a starting point or more instructions? Head over to{" "}
|
value={query}
|
||||||
<a
|
onChange={(e) => setQuery(e.target.value)}
|
||||||
href="https://vercel.com/templates?framework=next.js&utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
placeholder="Search for anything..."
|
||||||
className="font-medium text-zinc-950 dark:text-zinc-50"
|
className="flex-1 bg-transparent outline-none text-foreground placeholder:text-muted-foreground"
|
||||||
>
|
autoFocus
|
||||||
Templates
|
|
||||||
</a>{" "}
|
|
||||||
or the{" "}
|
|
||||||
<a
|
|
||||||
href="https://nextjs.org/learn?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
|
||||||
className="font-medium text-zinc-950 dark:text-zinc-50"
|
|
||||||
>
|
|
||||||
Learning
|
|
||||||
</a>{" "}
|
|
||||||
center.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-col gap-4 text-base font-medium sm:flex-row">
|
|
||||||
<a
|
|
||||||
className="flex h-12 w-full items-center justify-center gap-2 rounded-full bg-foreground px-5 text-background transition-colors hover:bg-[#383838] dark:hover:bg-[#ccc] md:w-[158px]"
|
|
||||||
href="https://vercel.com/new?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
>
|
|
||||||
<Image
|
|
||||||
className="dark:invert"
|
|
||||||
src="/vercel.svg"
|
|
||||||
alt="Vercel logomark"
|
|
||||||
width={16}
|
|
||||||
height={16}
|
|
||||||
/>
|
/>
|
||||||
Deploy Now
|
</div>
|
||||||
</a>
|
</form>
|
||||||
<a
|
|
||||||
className="flex h-12 w-full items-center justify-center rounded-full border border-solid border-black/[.08] px-5 transition-colors hover:border-transparent hover:bg-black/[.04] dark:border-white/[.145] dark:hover:bg-[#1a1a1a] md:w-[158px]"
|
<div className="flex gap-3 justify-center">
|
||||||
href="https://nextjs.org/docs?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
<button
|
||||||
target="_blank"
|
type="button"
|
||||||
rel="noopener noreferrer"
|
onClick={handleSearch}
|
||||||
|
className="px-5 py-2.5 text-sm font-medium bg-foreground text-background rounded-lg hover:opacity-90 transition-opacity"
|
||||||
>
|
>
|
||||||
Documentation
|
Search
|
||||||
</a>
|
</button>
|
||||||
|
<PageTransition href="/random">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="px-5 py-2.5 text-sm font-medium text-muted-foreground border border-border rounded-lg hover:bg-accent hover:text-foreground transition-colors inline-flex items-center gap-2"
|
||||||
|
>
|
||||||
|
<Shuffle className="w-4 h-4" />
|
||||||
|
Random
|
||||||
|
</button>
|
||||||
|
</PageTransition>
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</div>
|
||||||
</div>
|
</main>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,34 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
|
||||||
|
export async function GET(request: NextRequest) {
|
||||||
|
const baseUrl = request.nextUrl.origin;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(
|
||||||
|
'https://en.wikipedia.org/api/rest_v1/page/random/summary',
|
||||||
|
{
|
||||||
|
headers: {
|
||||||
|
'User-Agent': 'WikiPlus/1.0',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('Failed to fetch from Wikipedia API');
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
// Convert title to URL-friendly format (replace spaces with underscores)
|
||||||
|
const urlTitle = data.title.replace(/ /g, '_');
|
||||||
|
|
||||||
|
return NextResponse.redirect(
|
||||||
|
new URL(`/summary/${encodeURIComponent(urlTitle)}`, baseUrl)
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching random article:', error);
|
||||||
|
return NextResponse.redirect(
|
||||||
|
new URL('/?error=failed', baseUrl)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,217 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useSearchParams, useRouter } from 'next/navigation';
|
||||||
|
import { useState, useEffect, useRef, FormEvent, Suspense } from 'react';
|
||||||
|
import Link from 'next/link';
|
||||||
|
import Image from 'next/image';
|
||||||
|
import { Search, ArrowLeft, FileText } from 'lucide-react';
|
||||||
|
|
||||||
|
interface SearchResult {
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
thumbnail?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function SearchContent() {
|
||||||
|
const searchParams = useSearchParams();
|
||||||
|
const router = useRouter();
|
||||||
|
const initialQuery = searchParams.get('query') || '';
|
||||||
|
|
||||||
|
const [query, setQuery] = useState(initialQuery);
|
||||||
|
const [results, setResults] = useState<SearchResult[]>([]);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [searched, setSearched] = useState(false);
|
||||||
|
const lastSearchedQuery = useRef<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (initialQuery && initialQuery !== lastSearchedQuery.current) {
|
||||||
|
lastSearchedQuery.current = initialQuery;
|
||||||
|
performSearch(initialQuery);
|
||||||
|
}
|
||||||
|
}, [initialQuery]);
|
||||||
|
|
||||||
|
const performSearch = async (searchQuery: string) => {
|
||||||
|
if (!searchQuery.trim()) return;
|
||||||
|
|
||||||
|
setLoading(true);
|
||||||
|
setSearched(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/search?query=${encodeURIComponent(searchQuery)}`);
|
||||||
|
const data = await response.json();
|
||||||
|
setResults(data.results || []);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Search failed:', error);
|
||||||
|
setResults([]);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSearch = (e: FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (query.trim()) {
|
||||||
|
router.push(`/search?query=${encodeURIComponent(query.trim())}`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main className="min-h-screen bg-background">
|
||||||
|
{/* Clean header */}
|
||||||
|
<header className="sticky top-0 z-10 bg-background/95 backdrop-blur-md border-b border-border/50">
|
||||||
|
<div className="max-w-3xl mx-auto px-6 py-4">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<Link
|
||||||
|
href="/"
|
||||||
|
className="p-2 -ml-2 text-muted-foreground hover:text-foreground transition-colors rounded-lg hover:bg-muted/50"
|
||||||
|
aria-label="Back to home"
|
||||||
|
>
|
||||||
|
<ArrowLeft className="w-5 h-5" />
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
<form onSubmit={handleSearch} className="flex-1">
|
||||||
|
<div className="flex items-center w-full px-4 py-2.5 rounded-xl border border-border/60 bg-muted/30 hover:bg-muted/50 focus-within:bg-background focus-within:border-foreground/20 focus-within:ring-1 focus-within:ring-foreground/10 transition-all">
|
||||||
|
<Search className="w-4 h-4 text-muted-foreground mr-3 flex-shrink-0" />
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={query}
|
||||||
|
onChange={(e) => setQuery(e.target.value)}
|
||||||
|
placeholder="Search Wikipedia..."
|
||||||
|
className="flex-1 bg-transparent outline-none text-foreground placeholder:text-muted-foreground/70 text-[15px]"
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
{query && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setQuery('')}
|
||||||
|
className="ml-2 text-muted-foreground hover:text-foreground transition-colors text-sm"
|
||||||
|
>
|
||||||
|
Clear
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{/* Results area */}
|
||||||
|
<div className="max-w-3xl mx-auto px-6 py-8">
|
||||||
|
{loading ? (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{[...Array(5)].map((_, i) => (
|
||||||
|
<div
|
||||||
|
key={i}
|
||||||
|
className="flex gap-4 animate-pulse"
|
||||||
|
style={{ animationDelay: `${i * 100}ms` }}
|
||||||
|
>
|
||||||
|
<div className="w-20 h-20 rounded-lg bg-muted/60 shrink-0" />
|
||||||
|
<div className="flex-1 py-1">
|
||||||
|
<div className="h-5 bg-muted/60 rounded-md w-2/5 mb-3" />
|
||||||
|
<div className="h-4 bg-muted/40 rounded-md w-4/5 mb-2" />
|
||||||
|
<div className="h-4 bg-muted/40 rounded-md w-3/5" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : results.length > 0 ? (
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-muted-foreground mb-6">
|
||||||
|
{results.length} result{results.length !== 1 ? 's' : ''} for <span className="text-foreground font-medium">"{initialQuery}"</span>
|
||||||
|
</p>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{results.map((result, index) => (
|
||||||
|
<Link
|
||||||
|
key={result.title}
|
||||||
|
href={`/article/${encodeURIComponent(result.title.replace(/ /g, '_'))}`}
|
||||||
|
className="group block p-4 -mx-4 rounded-xl hover:bg-muted/40 active:bg-muted/60 transition-all duration-200"
|
||||||
|
style={{
|
||||||
|
animation: 'searchResultFadeIn 0.3s ease-out forwards',
|
||||||
|
animationDelay: `${index * 50}ms`,
|
||||||
|
opacity: 0
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="flex gap-4">
|
||||||
|
{result.thumbnail ? (
|
||||||
|
<div className="relative w-20 h-20 rounded-lg overflow-hidden bg-muted shrink-0 ring-1 ring-border/50">
|
||||||
|
<Image
|
||||||
|
src={result.thumbnail.startsWith('//') ? `https:${result.thumbnail}` : result.thumbnail}
|
||||||
|
alt=""
|
||||||
|
fill
|
||||||
|
className="object-cover group-hover:scale-105 transition-transform duration-300"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="w-20 h-20 rounded-lg bg-muted/50 shrink-0 flex items-center justify-center ring-1 ring-border/30">
|
||||||
|
<FileText className="w-6 h-6 text-muted-foreground/50" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="flex-1 min-w-0 py-0.5">
|
||||||
|
<h2 className="text-[17px] font-medium text-foreground group-hover:text-blue-600 dark:group-hover:text-blue-400 transition-colors leading-snug">
|
||||||
|
{result.title}
|
||||||
|
</h2>
|
||||||
|
{result.description && (
|
||||||
|
<p className="text-[14px] text-muted-foreground line-clamp-2 mt-1.5 leading-relaxed">
|
||||||
|
{result.description}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : searched ? (
|
||||||
|
<div className="text-center py-16">
|
||||||
|
<div className="w-16 h-16 mx-auto mb-4 rounded-full bg-muted/50 flex items-center justify-center">
|
||||||
|
<Search className="w-7 h-7 text-muted-foreground/50" />
|
||||||
|
</div>
|
||||||
|
<p className="text-muted-foreground">
|
||||||
|
No results found for <span className="text-foreground font-medium">"{initialQuery}"</span>
|
||||||
|
</p>
|
||||||
|
<p className="text-sm text-muted-foreground/70 mt-2">
|
||||||
|
Try different keywords or check the spelling
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="text-center py-16">
|
||||||
|
<div className="w-16 h-16 mx-auto mb-4 rounded-full bg-muted/50 flex items-center justify-center">
|
||||||
|
<Search className="w-7 h-7 text-muted-foreground/50" />
|
||||||
|
</div>
|
||||||
|
<p className="text-muted-foreground">
|
||||||
|
Enter a search term to find articles
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style jsx>{`
|
||||||
|
@keyframes searchResultFadeIn {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(8px);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`}</style>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function SearchPage() {
|
||||||
|
return (
|
||||||
|
<Suspense fallback={
|
||||||
|
<main className="min-h-screen bg-background flex items-center justify-center">
|
||||||
|
<div className="text-muted-foreground">
|
||||||
|
<div className="w-8 h-8 border-2 border-muted-foreground/30 border-t-muted-foreground rounded-full animate-spin mx-auto mb-3" />
|
||||||
|
<span className="text-sm">Loading...</span>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
}>
|
||||||
|
<SearchContent />
|
||||||
|
</Suspense>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,114 @@
|
|||||||
|
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||||
|
import { faArrowsRotate, faArrowUpRightFromSquare } from '@fortawesome/free-solid-svg-icons';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import Image from 'next/image';
|
||||||
|
import Link from 'next/link';
|
||||||
|
import { notFound } from 'next/navigation';
|
||||||
|
import { PageTransition } from '@/components/page-transition';
|
||||||
|
|
||||||
|
interface WikipediaSummary {
|
||||||
|
title: string;
|
||||||
|
extract: string;
|
||||||
|
thumbnail?: {
|
||||||
|
source: string;
|
||||||
|
};
|
||||||
|
originalimage?: {
|
||||||
|
source: string;
|
||||||
|
};
|
||||||
|
content_urls?: {
|
||||||
|
desktop?: {
|
||||||
|
page: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getArticleSummary(title: string): Promise<WikipediaSummary | null> {
|
||||||
|
try {
|
||||||
|
// Wikipedia API expects underscores instead of %20 for spaces
|
||||||
|
const apiTitle = title.replace(/%20/g, '_');
|
||||||
|
|
||||||
|
const response = await fetch(
|
||||||
|
`https://en.wikipedia.org/api/rest_v1/page/summary/${apiTitle}`,
|
||||||
|
{
|
||||||
|
headers: {
|
||||||
|
'User-Agent': 'WikiPlus/1.0',
|
||||||
|
},
|
||||||
|
cache: 'no-store',
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return await response.json();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching article summary:', error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function SummaryPage({
|
||||||
|
params,
|
||||||
|
}: {
|
||||||
|
params: Promise<{ title: string }>;
|
||||||
|
}) {
|
||||||
|
const { title } = await params;
|
||||||
|
const article = await getArticleSummary(title);
|
||||||
|
|
||||||
|
if (!article) {
|
||||||
|
notFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
const imageUrl = article.thumbnail?.source || article.originalimage?.source;
|
||||||
|
const pageUrl = article.content_urls?.desktop?.page;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main className="min-h-screen py-10 sm:py-16 px-4 sm:px-6 overflow-hidden">
|
||||||
|
<div className="max-w-2xl mx-auto">
|
||||||
|
<header className="mb-8 sm:mb-12 animate-[slideInFromTop_0.6s_ease-out]">
|
||||||
|
<Link href="/" className="text-gray-500 hover:text-gray-300 text-sm uppercase tracking-widest mb-4 inline-block transition-colors">
|
||||||
|
Wiki<span className="text-blue-500">+</span>
|
||||||
|
</Link>
|
||||||
|
<h1 className="text-3xl sm:text-4xl md:text-5xl font-light text-gray-100 tracking-tight">
|
||||||
|
{article.title}
|
||||||
|
</h1>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{imageUrl && (
|
||||||
|
<div className="relative w-full aspect-video mb-8 sm:mb-10 rounded-lg overflow-hidden bg-gray-800/50 animate-[fadeInScale_0.8s_ease-out_0.2s_both]">
|
||||||
|
<Image
|
||||||
|
src={imageUrl}
|
||||||
|
alt={article.title}
|
||||||
|
fill
|
||||||
|
className="object-contain"
|
||||||
|
priority
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<article className="mb-8 sm:mb-12 animate-[slideInFromBottom_0.6s_ease-out_0.4s_both]">
|
||||||
|
<p className="text-gray-300 text-lg sm:text-xl leading-relaxed font-light">
|
||||||
|
{article.extract}
|
||||||
|
</p>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<footer className="flex flex-col-reverse sm:flex-row items-center justify-between gap-4 pt-6 sm:pt-8 border-t border-gray-800 animate-[slideInFromBottom_0.6s_ease-out_0.6s_both]">
|
||||||
|
<Link
|
||||||
|
href={`/article/${encodeURIComponent(article.title)}`}
|
||||||
|
className="text-gray-500 hover:text-gray-300 text-sm inline-flex items-center gap-2 transition-colors"
|
||||||
|
>
|
||||||
|
Full article
|
||||||
|
<FontAwesomeIcon icon={faArrowUpRightFromSquare} className="text-xs" />
|
||||||
|
</Link>
|
||||||
|
<PageTransition href="/random">
|
||||||
|
<Button variant="outline" className="gap-2 hover:scale-105 active:scale-95 transition-transform w-full sm:w-auto">
|
||||||
|
<FontAwesomeIcon icon={faArrowsRotate} />
|
||||||
|
New Article
|
||||||
|
</Button>
|
||||||
|
</PageTransition>
|
||||||
|
</footer>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://ui.shadcn.com/schema.json",
|
||||||
|
"style": "new-york",
|
||||||
|
"rsc": true,
|
||||||
|
"tsx": true,
|
||||||
|
"tailwind": {
|
||||||
|
"config": "",
|
||||||
|
"css": "app/globals.css",
|
||||||
|
"baseColor": "neutral",
|
||||||
|
"cssVariables": true,
|
||||||
|
"prefix": ""
|
||||||
|
},
|
||||||
|
"iconLibrary": "lucide",
|
||||||
|
"aliases": {
|
||||||
|
"components": "@/components",
|
||||||
|
"utils": "@/lib/utils",
|
||||||
|
"ui": "@/components/ui",
|
||||||
|
"lib": "@/lib",
|
||||||
|
"hooks": "@/hooks"
|
||||||
|
},
|
||||||
|
"registries": {}
|
||||||
|
}
|
||||||
@@ -0,0 +1,791 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState, useRef, useCallback, useEffect, useMemo } from 'react';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { ReviewResultPanel } from './review-result-panel';
|
||||||
|
import { EditorToolbar } from './editor-toolbar';
|
||||||
|
import { EditorPreview } from './editor-preview';
|
||||||
|
import { DiffPreview } from './diff-preview';
|
||||||
|
import {
|
||||||
|
Maximize2,
|
||||||
|
Minimize2,
|
||||||
|
Eye,
|
||||||
|
EyeOff,
|
||||||
|
Search,
|
||||||
|
X,
|
||||||
|
ChevronDown,
|
||||||
|
FileText,
|
||||||
|
Save,
|
||||||
|
AlertCircle,
|
||||||
|
} from 'lucide-react';
|
||||||
|
|
||||||
|
interface ArticleEditorProps {
|
||||||
|
slug: string;
|
||||||
|
initialContent: string;
|
||||||
|
onCancel: () => void;
|
||||||
|
onSaved: (newContent: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ReviewResult {
|
||||||
|
approved: boolean;
|
||||||
|
reason: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface FindReplaceState {
|
||||||
|
isOpen: boolean;
|
||||||
|
findText: string;
|
||||||
|
replaceText: string;
|
||||||
|
matchCount: number;
|
||||||
|
currentMatch: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ArticleEditor({
|
||||||
|
slug,
|
||||||
|
initialContent,
|
||||||
|
onCancel,
|
||||||
|
onSaved,
|
||||||
|
}: ArticleEditorProps) {
|
||||||
|
const [content, setContent] = useState(initialContent);
|
||||||
|
const [isReviewing, setIsReviewing] = useState(false);
|
||||||
|
const [reviewResponse, setReviewResponse] = useState('');
|
||||||
|
const [reviewResult, setReviewResult] = useState<ReviewResult | null>(null);
|
||||||
|
const [isSaving, setIsSaving] = useState(false);
|
||||||
|
const [isFullscreen, setIsFullscreen] = useState(false);
|
||||||
|
const [showPreview, setShowPreview] = useState(true);
|
||||||
|
const [showDiff, setShowDiff] = useState(false);
|
||||||
|
const [undoStack, setUndoStack] = useState<string[]>([initialContent]);
|
||||||
|
const [redoStack, setRedoStack] = useState<string[]>([]);
|
||||||
|
const [findReplace, setFindReplace] = useState<FindReplaceState>({
|
||||||
|
isOpen: false,
|
||||||
|
findText: '',
|
||||||
|
replaceText: '',
|
||||||
|
matchCount: 0,
|
||||||
|
currentMatch: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||||
|
const previewRef = useRef<HTMLDivElement>(null);
|
||||||
|
const editorContainerRef = useRef<HTMLDivElement>(null);
|
||||||
|
const lastSavedContent = useRef(initialContent);
|
||||||
|
const isScrollingSyncRef = useRef(false);
|
||||||
|
const autoSaveTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||||
|
const [autoSaveStatus, setAutoSaveStatus] = useState<'idle' | 'saving' | 'saved'>('idle');
|
||||||
|
const [showRecoveryBanner, setShowRecoveryBanner] = useState(false);
|
||||||
|
|
||||||
|
// Word and character count
|
||||||
|
const wordCount = content.trim() ? content.trim().split(/\s+/).length : 0;
|
||||||
|
const charCount = content.length;
|
||||||
|
const lineCount = content.split('\n').length;
|
||||||
|
const hasChanges = content !== initialContent;
|
||||||
|
|
||||||
|
// Storage key for auto-save
|
||||||
|
const storageKey = `article-draft-${slug}`;
|
||||||
|
|
||||||
|
// Check for recovered draft on mount
|
||||||
|
useEffect(() => {
|
||||||
|
const savedDraft = localStorage.getItem(storageKey);
|
||||||
|
if (savedDraft && savedDraft !== initialContent && savedDraft.length > 0) {
|
||||||
|
setShowRecoveryBanner(true);
|
||||||
|
}
|
||||||
|
}, [storageKey, initialContent]);
|
||||||
|
|
||||||
|
// Auto-save to localStorage
|
||||||
|
useEffect(() => {
|
||||||
|
if (hasChanges) {
|
||||||
|
if (autoSaveTimeoutRef.current) {
|
||||||
|
clearTimeout(autoSaveTimeoutRef.current);
|
||||||
|
}
|
||||||
|
setAutoSaveStatus('saving');
|
||||||
|
autoSaveTimeoutRef.current = setTimeout(() => {
|
||||||
|
localStorage.setItem(storageKey, content);
|
||||||
|
setAutoSaveStatus('saved');
|
||||||
|
setTimeout(() => setAutoSaveStatus('idle'), 2000);
|
||||||
|
}, 1000);
|
||||||
|
}
|
||||||
|
return () => {
|
||||||
|
if (autoSaveTimeoutRef.current) {
|
||||||
|
clearTimeout(autoSaveTimeoutRef.current);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, [content, hasChanges, storageKey]);
|
||||||
|
|
||||||
|
// Clear draft on successful save
|
||||||
|
const clearDraft = useCallback(() => {
|
||||||
|
localStorage.removeItem(storageKey);
|
||||||
|
}, [storageKey]);
|
||||||
|
|
||||||
|
const recoverDraft = useCallback(() => {
|
||||||
|
const savedDraft = localStorage.getItem(storageKey);
|
||||||
|
if (savedDraft) {
|
||||||
|
updateContent(savedDraft);
|
||||||
|
setShowRecoveryBanner(false);
|
||||||
|
}
|
||||||
|
}, [storageKey]);
|
||||||
|
|
||||||
|
const dismissRecovery = useCallback(() => {
|
||||||
|
localStorage.removeItem(storageKey);
|
||||||
|
setShowRecoveryBanner(false);
|
||||||
|
}, [storageKey]);
|
||||||
|
|
||||||
|
// Synchronized scrolling between editor and preview
|
||||||
|
const handleEditorScroll = useCallback(() => {
|
||||||
|
if (isScrollingSyncRef.current || !textareaRef.current || !previewRef.current || !showPreview) return;
|
||||||
|
|
||||||
|
isScrollingSyncRef.current = true;
|
||||||
|
const textarea = textareaRef.current;
|
||||||
|
const preview = previewRef.current;
|
||||||
|
|
||||||
|
const scrollPercentage = textarea.scrollTop / (textarea.scrollHeight - textarea.clientHeight);
|
||||||
|
const previewScrollTop = scrollPercentage * (preview.scrollHeight - preview.clientHeight);
|
||||||
|
|
||||||
|
preview.scrollTop = previewScrollTop;
|
||||||
|
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
isScrollingSyncRef.current = false;
|
||||||
|
});
|
||||||
|
}, [showPreview]);
|
||||||
|
|
||||||
|
const handlePreviewScroll = useCallback(() => {
|
||||||
|
if (isScrollingSyncRef.current || !textareaRef.current || !previewRef.current || !showPreview) return;
|
||||||
|
|
||||||
|
isScrollingSyncRef.current = true;
|
||||||
|
const textarea = textareaRef.current;
|
||||||
|
const preview = previewRef.current;
|
||||||
|
|
||||||
|
const scrollPercentage = preview.scrollTop / (preview.scrollHeight - preview.clientHeight);
|
||||||
|
const textareaScrollTop = scrollPercentage * (textarea.scrollHeight - textarea.clientHeight);
|
||||||
|
|
||||||
|
textarea.scrollTop = textareaScrollTop;
|
||||||
|
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
isScrollingSyncRef.current = false;
|
||||||
|
});
|
||||||
|
}, [showPreview]);
|
||||||
|
|
||||||
|
// Update match count when find text changes
|
||||||
|
useEffect(() => {
|
||||||
|
if (findReplace.findText) {
|
||||||
|
const regex = new RegExp(escapeRegex(findReplace.findText), 'gi');
|
||||||
|
const matches = content.match(regex);
|
||||||
|
setFindReplace(prev => ({
|
||||||
|
...prev,
|
||||||
|
matchCount: matches?.length || 0,
|
||||||
|
currentMatch: matches?.length ? 1 : 0,
|
||||||
|
}));
|
||||||
|
} else {
|
||||||
|
setFindReplace(prev => ({ ...prev, matchCount: 0, currentMatch: 0 }));
|
||||||
|
}
|
||||||
|
}, [findReplace.findText, content]);
|
||||||
|
|
||||||
|
// Keyboard shortcuts
|
||||||
|
useEffect(() => {
|
||||||
|
const handleKeyDown = (e: KeyboardEvent) => {
|
||||||
|
if (!textareaRef.current) return;
|
||||||
|
|
||||||
|
const isMod = e.ctrlKey || e.metaKey;
|
||||||
|
|
||||||
|
if (isMod && e.key === 'b') {
|
||||||
|
e.preventDefault();
|
||||||
|
wrapSelection('**', '**');
|
||||||
|
} else if (isMod && e.key === 'i') {
|
||||||
|
e.preventDefault();
|
||||||
|
wrapSelection('*', '*');
|
||||||
|
} else if (isMod && e.key === 'k') {
|
||||||
|
e.preventDefault();
|
||||||
|
insertLink();
|
||||||
|
} else if (isMod && e.key === 'f') {
|
||||||
|
e.preventDefault();
|
||||||
|
setFindReplace(prev => ({ ...prev, isOpen: true }));
|
||||||
|
} else if (isMod && e.shiftKey && e.key === 'z') {
|
||||||
|
e.preventDefault();
|
||||||
|
handleRedo();
|
||||||
|
} else if (isMod && e.key === 'z') {
|
||||||
|
e.preventDefault();
|
||||||
|
handleUndo();
|
||||||
|
} else if (e.key === 'Escape') {
|
||||||
|
if (findReplace.isOpen) {
|
||||||
|
setFindReplace(prev => ({ ...prev, isOpen: false }));
|
||||||
|
} else if (isFullscreen) {
|
||||||
|
setIsFullscreen(false);
|
||||||
|
}
|
||||||
|
} else if (e.key === 'Tab' && document.activeElement === textareaRef.current) {
|
||||||
|
e.preventDefault();
|
||||||
|
insertAtCursor(' ');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
document.addEventListener('keydown', handleKeyDown);
|
||||||
|
return () => document.removeEventListener('keydown', handleKeyDown);
|
||||||
|
}, [content, findReplace.isOpen, isFullscreen]);
|
||||||
|
|
||||||
|
const escapeRegex = (str: string) => str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||||
|
|
||||||
|
const updateContent = useCallback((newContent: string) => {
|
||||||
|
setUndoStack(prev => [...prev.slice(-50), content]);
|
||||||
|
setRedoStack([]);
|
||||||
|
setContent(newContent);
|
||||||
|
}, [content]);
|
||||||
|
|
||||||
|
const handleUndo = useCallback(() => {
|
||||||
|
if (undoStack.length > 1) {
|
||||||
|
const newUndo = [...undoStack];
|
||||||
|
const current = newUndo.pop()!;
|
||||||
|
const previous = newUndo[newUndo.length - 1];
|
||||||
|
setRedoStack(prev => [...prev, current]);
|
||||||
|
setUndoStack(newUndo);
|
||||||
|
setContent(previous);
|
||||||
|
}
|
||||||
|
}, [undoStack]);
|
||||||
|
|
||||||
|
const handleRedo = useCallback(() => {
|
||||||
|
if (redoStack.length > 0) {
|
||||||
|
const newRedo = [...redoStack];
|
||||||
|
const next = newRedo.pop()!;
|
||||||
|
setUndoStack(prev => [...prev, content]);
|
||||||
|
setRedoStack(newRedo);
|
||||||
|
setContent(next);
|
||||||
|
}
|
||||||
|
}, [redoStack, content]);
|
||||||
|
|
||||||
|
const wrapSelection = useCallback((before: string, after: string) => {
|
||||||
|
const textarea = textareaRef.current;
|
||||||
|
if (!textarea) return;
|
||||||
|
|
||||||
|
const start = textarea.selectionStart;
|
||||||
|
const end = textarea.selectionEnd;
|
||||||
|
const selectedText = content.substring(start, end);
|
||||||
|
|
||||||
|
const newText = content.substring(0, start) + before + selectedText + after + content.substring(end);
|
||||||
|
updateContent(newText);
|
||||||
|
|
||||||
|
// Restore selection
|
||||||
|
setTimeout(() => {
|
||||||
|
textarea.focus();
|
||||||
|
textarea.setSelectionRange(start + before.length, end + before.length);
|
||||||
|
}, 0);
|
||||||
|
}, [content, updateContent]);
|
||||||
|
|
||||||
|
const insertAtCursor = useCallback((text: string) => {
|
||||||
|
const textarea = textareaRef.current;
|
||||||
|
if (!textarea) return;
|
||||||
|
|
||||||
|
const start = textarea.selectionStart;
|
||||||
|
const end = textarea.selectionEnd;
|
||||||
|
|
||||||
|
const newText = content.substring(0, start) + text + content.substring(end);
|
||||||
|
updateContent(newText);
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
textarea.focus();
|
||||||
|
textarea.setSelectionRange(start + text.length, start + text.length);
|
||||||
|
}, 0);
|
||||||
|
}, [content, updateContent]);
|
||||||
|
|
||||||
|
const insertLink = useCallback(() => {
|
||||||
|
const textarea = textareaRef.current;
|
||||||
|
if (!textarea) return;
|
||||||
|
|
||||||
|
const start = textarea.selectionStart;
|
||||||
|
const end = textarea.selectionEnd;
|
||||||
|
const selectedText = content.substring(start, end);
|
||||||
|
|
||||||
|
if (selectedText) {
|
||||||
|
wrapSelection('[', '](url)');
|
||||||
|
} else {
|
||||||
|
insertAtCursor('[link text](url)');
|
||||||
|
}
|
||||||
|
}, [content, wrapSelection, insertAtCursor]);
|
||||||
|
|
||||||
|
const insertHeading = useCallback((level: number) => {
|
||||||
|
const textarea = textareaRef.current;
|
||||||
|
if (!textarea) return;
|
||||||
|
|
||||||
|
const start = textarea.selectionStart;
|
||||||
|
const lineStart = content.lastIndexOf('\n', start - 1) + 1;
|
||||||
|
const prefix = '#'.repeat(level) + ' ';
|
||||||
|
|
||||||
|
const newText = content.substring(0, lineStart) + prefix + content.substring(lineStart);
|
||||||
|
updateContent(newText);
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
textarea.focus();
|
||||||
|
textarea.setSelectionRange(lineStart + prefix.length, lineStart + prefix.length);
|
||||||
|
}, 0);
|
||||||
|
}, [content, updateContent]);
|
||||||
|
|
||||||
|
const insertList = useCallback((ordered: boolean) => {
|
||||||
|
const textarea = textareaRef.current;
|
||||||
|
if (!textarea) return;
|
||||||
|
|
||||||
|
const start = textarea.selectionStart;
|
||||||
|
const end = textarea.selectionEnd;
|
||||||
|
const selectedText = content.substring(start, end);
|
||||||
|
|
||||||
|
if (selectedText) {
|
||||||
|
const lines = selectedText.split('\n');
|
||||||
|
const listLines = lines.map((line, i) =>
|
||||||
|
ordered ? `${i + 1}. ${line}` : `- ${line}`
|
||||||
|
).join('\n');
|
||||||
|
|
||||||
|
const newText = content.substring(0, start) + listLines + content.substring(end);
|
||||||
|
updateContent(newText);
|
||||||
|
} else {
|
||||||
|
insertAtCursor(ordered ? '\n1. ' : '\n- ');
|
||||||
|
}
|
||||||
|
}, [content, updateContent, insertAtCursor]);
|
||||||
|
|
||||||
|
const insertBlockquote = useCallback(() => {
|
||||||
|
const textarea = textareaRef.current;
|
||||||
|
if (!textarea) return;
|
||||||
|
|
||||||
|
const start = textarea.selectionStart;
|
||||||
|
const end = textarea.selectionEnd;
|
||||||
|
const selectedText = content.substring(start, end);
|
||||||
|
|
||||||
|
if (selectedText) {
|
||||||
|
const lines = selectedText.split('\n');
|
||||||
|
const quotedLines = lines.map(line => `> ${line}`).join('\n');
|
||||||
|
const newText = content.substring(0, start) + quotedLines + content.substring(end);
|
||||||
|
updateContent(newText);
|
||||||
|
} else {
|
||||||
|
insertAtCursor('\n> ');
|
||||||
|
}
|
||||||
|
}, [content, updateContent, insertAtCursor]);
|
||||||
|
|
||||||
|
const insertCodeBlock = useCallback(() => {
|
||||||
|
const textarea = textareaRef.current;
|
||||||
|
if (!textarea) return;
|
||||||
|
|
||||||
|
const start = textarea.selectionStart;
|
||||||
|
const end = textarea.selectionEnd;
|
||||||
|
const selectedText = content.substring(start, end);
|
||||||
|
|
||||||
|
if (selectedText) {
|
||||||
|
wrapSelection('\n```\n', '\n```\n');
|
||||||
|
} else {
|
||||||
|
insertAtCursor('\n```\ncode here\n```\n');
|
||||||
|
}
|
||||||
|
}, [wrapSelection, insertAtCursor]);
|
||||||
|
|
||||||
|
const insertTable = useCallback(() => {
|
||||||
|
const table = `
|
||||||
|
| Header 1 | Header 2 | Header 3 |
|
||||||
|
|----------|----------|----------|
|
||||||
|
| Cell 1 | Cell 2 | Cell 3 |
|
||||||
|
| Cell 4 | Cell 5 | Cell 6 |
|
||||||
|
`;
|
||||||
|
insertAtCursor(table);
|
||||||
|
}, [insertAtCursor]);
|
||||||
|
|
||||||
|
const insertTemplate = useCallback((template: string) => {
|
||||||
|
insertAtCursor('\n' + template + '\n');
|
||||||
|
}, [insertAtCursor]);
|
||||||
|
|
||||||
|
const insertImage = useCallback(() => {
|
||||||
|
const textarea = textareaRef.current;
|
||||||
|
if (!textarea) return;
|
||||||
|
|
||||||
|
const start = textarea.selectionStart;
|
||||||
|
const end = textarea.selectionEnd;
|
||||||
|
const selectedText = content.substring(start, end);
|
||||||
|
|
||||||
|
if (selectedText && (selectedText.startsWith('http') || selectedText.startsWith('/'))) {
|
||||||
|
const newText = content.substring(0, start) + `` + content.substring(end);
|
||||||
|
updateContent(newText);
|
||||||
|
} else {
|
||||||
|
insertAtCursor('');
|
||||||
|
}
|
||||||
|
}, [content, updateContent, insertAtCursor]);
|
||||||
|
|
||||||
|
const handleFindNext = useCallback(() => {
|
||||||
|
if (!findReplace.findText || !textareaRef.current) return;
|
||||||
|
|
||||||
|
const textarea = textareaRef.current;
|
||||||
|
const start = textarea.selectionEnd;
|
||||||
|
const regex = new RegExp(escapeRegex(findReplace.findText), 'gi');
|
||||||
|
|
||||||
|
// Search from current position
|
||||||
|
const afterCursor = content.substring(start);
|
||||||
|
const match = regex.exec(afterCursor);
|
||||||
|
|
||||||
|
if (match) {
|
||||||
|
const matchStart = start + match.index;
|
||||||
|
const matchEnd = matchStart + match[0].length;
|
||||||
|
textarea.focus();
|
||||||
|
textarea.setSelectionRange(matchStart, matchEnd);
|
||||||
|
setFindReplace(prev => ({
|
||||||
|
...prev,
|
||||||
|
currentMatch: Math.min(prev.currentMatch + 1, prev.matchCount),
|
||||||
|
}));
|
||||||
|
} else {
|
||||||
|
// Wrap around to beginning
|
||||||
|
const beforeCursor = content.substring(0, start);
|
||||||
|
const wrapMatch = new RegExp(escapeRegex(findReplace.findText), 'gi').exec(content);
|
||||||
|
if (wrapMatch) {
|
||||||
|
textarea.focus();
|
||||||
|
textarea.setSelectionRange(wrapMatch.index, wrapMatch.index + wrapMatch[0].length);
|
||||||
|
setFindReplace(prev => ({ ...prev, currentMatch: 1 }));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [findReplace.findText, content]);
|
||||||
|
|
||||||
|
const handleReplace = useCallback(() => {
|
||||||
|
if (!findReplace.findText || !textareaRef.current) return;
|
||||||
|
|
||||||
|
const textarea = textareaRef.current;
|
||||||
|
const start = textarea.selectionStart;
|
||||||
|
const end = textarea.selectionEnd;
|
||||||
|
const selectedText = content.substring(start, end);
|
||||||
|
|
||||||
|
if (selectedText.toLowerCase() === findReplace.findText.toLowerCase()) {
|
||||||
|
const newText = content.substring(0, start) + findReplace.replaceText + content.substring(end);
|
||||||
|
updateContent(newText);
|
||||||
|
handleFindNext();
|
||||||
|
} else {
|
||||||
|
handleFindNext();
|
||||||
|
}
|
||||||
|
}, [findReplace, content, updateContent, handleFindNext]);
|
||||||
|
|
||||||
|
const handleReplaceAll = useCallback(() => {
|
||||||
|
if (!findReplace.findText) return;
|
||||||
|
|
||||||
|
const regex = new RegExp(escapeRegex(findReplace.findText), 'gi');
|
||||||
|
const newText = content.replace(regex, findReplace.replaceText);
|
||||||
|
updateContent(newText);
|
||||||
|
}, [findReplace, content, updateContent]);
|
||||||
|
|
||||||
|
const parseReviewAction = (text: string): ReviewResult | null => {
|
||||||
|
const match = text.match(/<action\s+approve="(true|false)"\s+reason="([^"]+)"\s*\/?>/i);
|
||||||
|
if (match) {
|
||||||
|
return {
|
||||||
|
approved: match[1].toLowerCase() === 'true',
|
||||||
|
reason: match[2],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleReview = async () => {
|
||||||
|
setIsReviewing(true);
|
||||||
|
setReviewResponse('');
|
||||||
|
setReviewResult(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/article/${encodeURIComponent(slug)}/review`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ newContent: content }),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('Failed to review changes');
|
||||||
|
}
|
||||||
|
|
||||||
|
const reader = response.body?.getReader();
|
||||||
|
if (!reader) throw new Error('No response body');
|
||||||
|
|
||||||
|
const decoder = new TextDecoder();
|
||||||
|
let fullResponse = '';
|
||||||
|
|
||||||
|
while (true) {
|
||||||
|
const { done, value } = await reader.read();
|
||||||
|
if (done) break;
|
||||||
|
|
||||||
|
const chunk = decoder.decode(value, { stream: true });
|
||||||
|
fullResponse += chunk;
|
||||||
|
setReviewResponse(fullResponse);
|
||||||
|
|
||||||
|
const result = parseReviewAction(fullResponse);
|
||||||
|
if (result) {
|
||||||
|
setReviewResult(result);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!reviewResult) {
|
||||||
|
const finalResult = parseReviewAction(fullResponse);
|
||||||
|
if (finalResult) {
|
||||||
|
setReviewResult(finalResult);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Review error:', error);
|
||||||
|
setReviewResult({
|
||||||
|
approved: false,
|
||||||
|
reason: 'Failed to complete review. Please try again.',
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setIsReviewing(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSave = async () => {
|
||||||
|
if (!reviewResult?.approved) return;
|
||||||
|
|
||||||
|
setIsSaving(true);
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/article/${encodeURIComponent(slug)}/save`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
newContent: content,
|
||||||
|
reason: reviewResult.reason,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('Failed to save changes');
|
||||||
|
}
|
||||||
|
|
||||||
|
lastSavedContent.current = content;
|
||||||
|
clearDraft();
|
||||||
|
onSaved(content);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Save error:', error);
|
||||||
|
alert('Failed to save changes. Please try again.');
|
||||||
|
} finally {
|
||||||
|
setIsSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleContinueEditing = () => {
|
||||||
|
setReviewResult(null);
|
||||||
|
setReviewResponse('');
|
||||||
|
setShowDiff(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const toolbarActions = {
|
||||||
|
onBold: () => wrapSelection('**', '**'),
|
||||||
|
onItalic: () => wrapSelection('*', '*'),
|
||||||
|
onStrikethrough: () => wrapSelection('~~', '~~'),
|
||||||
|
onHeading: insertHeading,
|
||||||
|
onLink: insertLink,
|
||||||
|
onImage: insertImage,
|
||||||
|
onBulletList: () => insertList(false),
|
||||||
|
onNumberedList: () => insertList(true),
|
||||||
|
onBlockquote: insertBlockquote,
|
||||||
|
onCode: () => wrapSelection('`', '`'),
|
||||||
|
onCodeBlock: insertCodeBlock,
|
||||||
|
onTable: insertTable,
|
||||||
|
onUndo: handleUndo,
|
||||||
|
onRedo: handleRedo,
|
||||||
|
canUndo: undoStack.length > 1,
|
||||||
|
canRedo: redoStack.length > 0,
|
||||||
|
onInsertTemplate: insertTemplate,
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={editorContainerRef}
|
||||||
|
className={`article-editor-container ${isFullscreen ? 'editor-fullscreen' : ''}`}
|
||||||
|
>
|
||||||
|
{/* Recovery banner */}
|
||||||
|
{showRecoveryBanner && (
|
||||||
|
<div className="recovery-banner">
|
||||||
|
<div className="recovery-banner-content">
|
||||||
|
<AlertCircle className="w-4 h-4" />
|
||||||
|
<span>A previous draft was found. Would you like to recover it?</span>
|
||||||
|
</div>
|
||||||
|
<div className="recovery-banner-actions">
|
||||||
|
<Button variant="ghost" size="xs" onClick={dismissRecovery}>
|
||||||
|
Discard
|
||||||
|
</Button>
|
||||||
|
<Button size="xs" onClick={recoverDraft}>
|
||||||
|
Recover Draft
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Header bar */}
|
||||||
|
<div className="editor-header">
|
||||||
|
<div className="editor-header-left">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={onCancel}
|
||||||
|
disabled={isReviewing || isSaving}
|
||||||
|
>
|
||||||
|
<X className="w-4 h-4 mr-1" />
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<div className="editor-status">
|
||||||
|
{hasChanges ? (
|
||||||
|
<span className="status-unsaved">
|
||||||
|
Unsaved changes
|
||||||
|
{autoSaveStatus === 'saving' && ' (saving...)'}
|
||||||
|
{autoSaveStatus === 'saved' && ' (draft saved)'}
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className="status-saved">No changes</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="editor-header-center">
|
||||||
|
<span className="editor-stats">
|
||||||
|
{wordCount.toLocaleString()} words · {charCount.toLocaleString()} chars · {lineCount.toLocaleString()} lines
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="editor-header-right">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setFindReplace(prev => ({ ...prev, isOpen: !prev.isOpen }))}
|
||||||
|
title="Find & Replace (Ctrl+F)"
|
||||||
|
>
|
||||||
|
<Search className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setShowPreview(!showPreview)}
|
||||||
|
title={showPreview ? 'Hide Preview' : 'Show Preview'}
|
||||||
|
>
|
||||||
|
{showPreview ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setShowDiff(!showDiff)}
|
||||||
|
disabled={!hasChanges}
|
||||||
|
title="Preview Changes"
|
||||||
|
>
|
||||||
|
<FileText className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setIsFullscreen(!isFullscreen)}
|
||||||
|
title={isFullscreen ? 'Exit Fullscreen' : 'Fullscreen'}
|
||||||
|
>
|
||||||
|
{isFullscreen ? <Minimize2 className="w-4 h-4" /> : <Maximize2 className="w-4 h-4" />}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={handleReview}
|
||||||
|
disabled={isReviewing || isSaving || !hasChanges}
|
||||||
|
size="sm"
|
||||||
|
>
|
||||||
|
{isReviewing ? 'Reviewing...' : 'Submit for Review'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Find & Replace bar */}
|
||||||
|
{findReplace.isOpen && (
|
||||||
|
<div className="find-replace-bar">
|
||||||
|
<div className="find-replace-inputs">
|
||||||
|
<div className="find-input-group">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Find..."
|
||||||
|
value={findReplace.findText}
|
||||||
|
onChange={(e) => setFindReplace(prev => ({ ...prev, findText: e.target.value }))}
|
||||||
|
className="find-input"
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
{findReplace.matchCount > 0 && (
|
||||||
|
<span className="match-count">
|
||||||
|
{findReplace.currentMatch} of {findReplace.matchCount}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Replace..."
|
||||||
|
value={findReplace.replaceText}
|
||||||
|
onChange={(e) => setFindReplace(prev => ({ ...prev, replaceText: e.target.value }))}
|
||||||
|
className="replace-input"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="find-replace-actions">
|
||||||
|
<Button variant="ghost" size="xs" onClick={handleFindNext} disabled={!findReplace.findText}>
|
||||||
|
<ChevronDown className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
<Button variant="ghost" size="xs" onClick={handleReplace} disabled={!findReplace.findText}>
|
||||||
|
Replace
|
||||||
|
</Button>
|
||||||
|
<Button variant="ghost" size="xs" onClick={handleReplaceAll} disabled={!findReplace.findText}>
|
||||||
|
Replace All
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="xs"
|
||||||
|
onClick={() => setFindReplace(prev => ({ ...prev, isOpen: false }))}
|
||||||
|
>
|
||||||
|
<X className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Toolbar */}
|
||||||
|
<EditorToolbar {...toolbarActions} disabled={isReviewing || isSaving} />
|
||||||
|
|
||||||
|
{/* Main editor area */}
|
||||||
|
<div className={`editor-main ${showPreview ? 'with-preview' : ''} ${showDiff ? 'with-diff' : ''}`}>
|
||||||
|
{showDiff && hasChanges ? (
|
||||||
|
<DiffPreview oldContent={initialContent} newContent={content} />
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="editor-pane">
|
||||||
|
<textarea
|
||||||
|
ref={textareaRef}
|
||||||
|
className="editor-textarea-enhanced"
|
||||||
|
value={content}
|
||||||
|
onChange={(e) => updateContent(e.target.value)}
|
||||||
|
onScroll={handleEditorScroll}
|
||||||
|
disabled={isReviewing || isSaving}
|
||||||
|
placeholder="Write your article content in markdown..."
|
||||||
|
spellCheck
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{showPreview && (
|
||||||
|
<div className="preview-pane">
|
||||||
|
<div className="preview-header">
|
||||||
|
<Eye className="w-4 h-4" />
|
||||||
|
<span>Preview</span>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
ref={previewRef}
|
||||||
|
className="preview-content"
|
||||||
|
onScroll={handlePreviewScroll}
|
||||||
|
>
|
||||||
|
<EditorPreview content={content} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Review section */}
|
||||||
|
{isReviewing && reviewResponse && (
|
||||||
|
<div className="review-stream">
|
||||||
|
<h4>AI Review in Progress...</h4>
|
||||||
|
<p className="review-stream-text">{reviewResponse}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{reviewResult && (
|
||||||
|
<ReviewResultPanel
|
||||||
|
approved={reviewResult.approved}
|
||||||
|
reason={reviewResult.reason}
|
||||||
|
onSave={handleSave}
|
||||||
|
onContinueEditing={handleContinueEditing}
|
||||||
|
isSaving={isSaving}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Keyboard shortcuts help */}
|
||||||
|
<div className="editor-shortcuts-hint">
|
||||||
|
<span>Ctrl+B Bold</span>
|
||||||
|
<span>Ctrl+I Italic</span>
|
||||||
|
<span>Ctrl+K Link</span>
|
||||||
|
<span>Ctrl+Z Undo</span>
|
||||||
|
<span>Ctrl+F Find</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,266 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState, useEffect, useMemo, useRef, useCallback } from 'react';
|
||||||
|
import ReactMarkdown from 'react-markdown';
|
||||||
|
import remarkGfm from 'remark-gfm';
|
||||||
|
import { TableOfContents } from './table-of-contents';
|
||||||
|
import { ArticleEditor } from './article-editor';
|
||||||
|
import { RelatedArticles } from './related-articles';
|
||||||
|
|
||||||
|
interface ArticleStreamProps {
|
||||||
|
title: string;
|
||||||
|
slug: string;
|
||||||
|
decodedTitle: string;
|
||||||
|
isEditMode: boolean;
|
||||||
|
onEditModeChange: (editing: boolean) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TocItem {
|
||||||
|
id: string;
|
||||||
|
text: string;
|
||||||
|
level: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
function generateSlug(text: string): string {
|
||||||
|
return text
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/[^a-z0-9\s-]/g, '')
|
||||||
|
.replace(/\s+/g, '-')
|
||||||
|
.replace(/-+/g, '-')
|
||||||
|
.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractHeadings(markdown: string): TocItem[] {
|
||||||
|
const headingRegex = /^(#{2,3})\s+(.+)$/gm;
|
||||||
|
const headings: TocItem[] = [];
|
||||||
|
let match;
|
||||||
|
|
||||||
|
while ((match = headingRegex.exec(markdown)) !== null) {
|
||||||
|
const level = match[1].length;
|
||||||
|
const text = match[2].replace(/\*\*/g, '').replace(/\*/g, '').trim();
|
||||||
|
const id = generateSlug(text);
|
||||||
|
headings.push({ id, text, level });
|
||||||
|
}
|
||||||
|
|
||||||
|
return headings;
|
||||||
|
}
|
||||||
|
|
||||||
|
const remarkPlugins = [remarkGfm];
|
||||||
|
|
||||||
|
export function ArticleStream({ title, slug, decodedTitle, isEditMode, onEditModeChange }: ArticleStreamProps) {
|
||||||
|
const [content, setContent] = useState('');
|
||||||
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const pendingContentRef = useRef<string | null>(null);
|
||||||
|
const rafIdRef = useRef<number | null>(null);
|
||||||
|
|
||||||
|
const headings = useMemo(() => extractHeadings(content), [content]);
|
||||||
|
|
||||||
|
const scheduleContentUpdate = useCallback((newContent: string) => {
|
||||||
|
pendingContentRef.current = newContent;
|
||||||
|
if (rafIdRef.current === null) {
|
||||||
|
rafIdRef.current = requestAnimationFrame(() => {
|
||||||
|
rafIdRef.current = null;
|
||||||
|
if (pendingContentRef.current !== null) {
|
||||||
|
setContent(pendingContentRef.current);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let isCancelled = false;
|
||||||
|
|
||||||
|
async function fetchStream() {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/ai-article?title=${encodeURIComponent(title)}`);
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('Failed to fetch article');
|
||||||
|
}
|
||||||
|
|
||||||
|
const reader = response.body?.getReader();
|
||||||
|
if (!reader) {
|
||||||
|
throw new Error('No response body');
|
||||||
|
}
|
||||||
|
|
||||||
|
const decoder = new TextDecoder();
|
||||||
|
let buffer = '';
|
||||||
|
let insideArticle = false;
|
||||||
|
let displayContent = '';
|
||||||
|
|
||||||
|
while (true) {
|
||||||
|
const { done, value } = await reader.read();
|
||||||
|
|
||||||
|
if (done || isCancelled) break;
|
||||||
|
|
||||||
|
const chunk = decoder.decode(value, { stream: true });
|
||||||
|
buffer += chunk;
|
||||||
|
|
||||||
|
if (!insideArticle && buffer.includes('<article>')) {
|
||||||
|
insideArticle = true;
|
||||||
|
setIsLoading(false);
|
||||||
|
buffer = buffer.split('<article>')[1] || '';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (insideArticle) {
|
||||||
|
if (buffer.includes('</article>')) {
|
||||||
|
displayContent += buffer.split('</article>')[0];
|
||||||
|
setContent(displayContent);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
displayContent += buffer;
|
||||||
|
buffer = '';
|
||||||
|
|
||||||
|
if (!isCancelled) {
|
||||||
|
scheduleContentUpdate(displayContent);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
reader.releaseLock();
|
||||||
|
} catch (err) {
|
||||||
|
if (!isCancelled) {
|
||||||
|
setError(err instanceof Error ? err.message : 'An error occurred');
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fetchStream();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
isCancelled = true;
|
||||||
|
if (rafIdRef.current !== null) {
|
||||||
|
cancelAnimationFrame(rafIdRef.current);
|
||||||
|
rafIdRef.current = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, [title, scheduleContentUpdate]);
|
||||||
|
|
||||||
|
const handleSaved = (newContent: string) => {
|
||||||
|
setContent(newContent);
|
||||||
|
onEditModeChange(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<div className="text-center py-12">
|
||||||
|
<p className="text-destructive mb-4">{error}</p>
|
||||||
|
<p className="text-muted-foreground">
|
||||||
|
Unable to generate article. Please try again later.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<div className="flex gap-8">
|
||||||
|
<aside className="hidden lg:block w-64 flex-shrink-0">
|
||||||
|
<div className="sticky top-24 space-y-8">
|
||||||
|
<div className="animate-pulse space-y-3">
|
||||||
|
<div className="h-4 bg-muted rounded w-24 mb-4" />
|
||||||
|
<div className="h-3 bg-muted rounded w-full" />
|
||||||
|
<div className="h-3 bg-muted rounded w-5/6" />
|
||||||
|
<div className="h-3 bg-muted rounded w-4/5" />
|
||||||
|
<div className="h-3 bg-muted rounded w-full" />
|
||||||
|
<div className="h-3 bg-muted rounded w-3/4" />
|
||||||
|
</div>
|
||||||
|
<div className="animate-pulse space-y-3">
|
||||||
|
<div className="h-4 bg-muted rounded w-24 mb-4" />
|
||||||
|
<div className="h-3 bg-muted rounded w-full" />
|
||||||
|
<div className="h-3 bg-muted rounded w-5/6" />
|
||||||
|
<div className="h-3 bg-muted rounded w-4/5" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
<div className="flex-1 min-w-0 animate-pulse space-y-4">
|
||||||
|
<div className="h-8 bg-muted rounded w-3/4" />
|
||||||
|
<div className="h-4 bg-muted rounded w-full" />
|
||||||
|
<div className="h-4 bg-muted rounded w-full" />
|
||||||
|
<div className="h-4 bg-muted rounded w-5/6" />
|
||||||
|
<div className="h-64 bg-muted rounded w-full mt-6" />
|
||||||
|
<div className="h-4 bg-muted rounded w-full" />
|
||||||
|
<div className="h-4 bg-muted rounded w-full" />
|
||||||
|
<div className="h-4 bg-muted rounded w-4/5" />
|
||||||
|
<div className="h-6 bg-muted rounded w-1/2 mt-6" />
|
||||||
|
<div className="h-4 bg-muted rounded w-full" />
|
||||||
|
<div className="h-4 bg-muted rounded w-full" />
|
||||||
|
<div className="h-4 bg-muted rounded w-3/4" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isEditMode) {
|
||||||
|
return (
|
||||||
|
<div className="flex gap-8">
|
||||||
|
<aside className="hidden lg:block w-64 flex-shrink-0">
|
||||||
|
<div className="sticky top-24">
|
||||||
|
<RelatedArticles title={decodedTitle} currentSlug={slug} />
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<ArticleEditor
|
||||||
|
slug={slug}
|
||||||
|
initialContent={content}
|
||||||
|
onCancel={() => onEditModeChange(false)}
|
||||||
|
onSaved={handleSaved}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex gap-8">
|
||||||
|
<aside className="hidden lg:block w-64 flex-shrink-0">
|
||||||
|
<div className="sticky top-24 space-y-8">
|
||||||
|
<TableOfContents headings={headings} />
|
||||||
|
<div className="border-t border-border pt-8">
|
||||||
|
<RelatedArticles title={decodedTitle} currentSlug={slug} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
<div className="flex-1 min-w-0 article-content">
|
||||||
|
<ReactMarkdown
|
||||||
|
remarkPlugins={remarkPlugins}
|
||||||
|
components={{
|
||||||
|
h2: ({ children }) => {
|
||||||
|
const text = String(children).replace(/\*\*/g, '').replace(/\*/g, '');
|
||||||
|
const id = generateSlug(text);
|
||||||
|
return <h2 id={id}>{children}</h2>;
|
||||||
|
},
|
||||||
|
h3: ({ children }) => {
|
||||||
|
const text = String(children).replace(/\*\*/g, '').replace(/\*/g, '');
|
||||||
|
const id = generateSlug(text);
|
||||||
|
return <h3 id={id}>{children}</h3>;
|
||||||
|
},
|
||||||
|
img: ({ src, alt }) => (
|
||||||
|
// eslint-disable-next-line @next/next/no-img-element
|
||||||
|
<img
|
||||||
|
src={src}
|
||||||
|
alt={alt || ''}
|
||||||
|
className="w-full max-w-md mx-auto rounded-lg my-6"
|
||||||
|
loading="lazy"
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
a: ({ href, children }) => (
|
||||||
|
<a
|
||||||
|
href={href}
|
||||||
|
target={href?.startsWith('http') ? '_blank' : undefined}
|
||||||
|
rel={href?.startsWith('http') ? 'noopener noreferrer' : undefined}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</a>
|
||||||
|
),
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{content}
|
||||||
|
</ReactMarkdown>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
|
||||||
|
export function CursorGlow() {
|
||||||
|
const [position, setPosition] = useState({ x: 0, y: 0 });
|
||||||
|
const [visible, setVisible] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handleMouseMove = (e: MouseEvent) => {
|
||||||
|
setPosition({ x: e.clientX, y: e.clientY });
|
||||||
|
if (!visible) setVisible(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleMouseLeave = () => setVisible(false);
|
||||||
|
const handleMouseEnter = () => setVisible(true);
|
||||||
|
|
||||||
|
window.addEventListener('mousemove', handleMouseMove);
|
||||||
|
document.body.addEventListener('mouseleave', handleMouseLeave);
|
||||||
|
document.body.addEventListener('mouseenter', handleMouseEnter);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('mousemove', handleMouseMove);
|
||||||
|
document.body.removeEventListener('mouseleave', handleMouseLeave);
|
||||||
|
document.body.removeEventListener('mouseenter', handleMouseEnter);
|
||||||
|
};
|
||||||
|
}, [visible]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="pointer-events-none fixed inset-0 z-50 transition-opacity duration-300"
|
||||||
|
style={{ opacity: visible ? 1 : 0 }}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="absolute h-[300px] w-[300px] rounded-full bg-blue-500/20 blur-[80px] transition-transform duration-150 ease-out"
|
||||||
|
style={{
|
||||||
|
transform: `translate(${position.x - 150}px, ${position.y - 150}px)`,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useMemo } from 'react';
|
||||||
|
import { diffLines } from 'diff';
|
||||||
|
|
||||||
|
interface DiffPreviewProps {
|
||||||
|
oldContent: string;
|
||||||
|
newContent: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DiffLine {
|
||||||
|
type: 'added' | 'removed' | 'unchanged';
|
||||||
|
content: string;
|
||||||
|
oldLineNum?: number;
|
||||||
|
newLineNum?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DiffPreview({ oldContent, newContent }: DiffPreviewProps) {
|
||||||
|
const diffResult = useMemo(() => {
|
||||||
|
const changes = diffLines(oldContent, newContent);
|
||||||
|
const lines: DiffLine[] = [];
|
||||||
|
let oldLineNum = 1;
|
||||||
|
let newLineNum = 1;
|
||||||
|
|
||||||
|
changes.forEach((change) => {
|
||||||
|
const changeLines = change.value.split('\n');
|
||||||
|
// Remove the last empty line if the value ends with \n
|
||||||
|
if (changeLines[changeLines.length - 1] === '') {
|
||||||
|
changeLines.pop();
|
||||||
|
}
|
||||||
|
|
||||||
|
changeLines.forEach((line) => {
|
||||||
|
if (change.added) {
|
||||||
|
lines.push({
|
||||||
|
type: 'added',
|
||||||
|
content: line,
|
||||||
|
newLineNum: newLineNum++,
|
||||||
|
});
|
||||||
|
} else if (change.removed) {
|
||||||
|
lines.push({
|
||||||
|
type: 'removed',
|
||||||
|
content: line,
|
||||||
|
oldLineNum: oldLineNum++,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
lines.push({
|
||||||
|
type: 'unchanged',
|
||||||
|
content: line,
|
||||||
|
oldLineNum: oldLineNum++,
|
||||||
|
newLineNum: newLineNum++,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return lines;
|
||||||
|
}, [oldContent, newContent]);
|
||||||
|
|
||||||
|
const stats = useMemo(() => {
|
||||||
|
let added = 0;
|
||||||
|
let removed = 0;
|
||||||
|
diffResult.forEach((line) => {
|
||||||
|
if (line.type === 'added') added++;
|
||||||
|
if (line.type === 'removed') removed++;
|
||||||
|
});
|
||||||
|
return { added, removed };
|
||||||
|
}, [diffResult]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="diff-preview-container">
|
||||||
|
<div className="diff-preview-header">
|
||||||
|
<span className="diff-preview-title">Changes Preview</span>
|
||||||
|
<div className="diff-preview-stats">
|
||||||
|
<span className="diff-stat-added">+{stats.added}</span>
|
||||||
|
<span className="diff-stat-removed">-{stats.removed}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="diff-preview-content">
|
||||||
|
{diffResult.map((line, index) => (
|
||||||
|
<div
|
||||||
|
key={index}
|
||||||
|
className={`diff-preview-line diff-preview-${line.type}`}
|
||||||
|
>
|
||||||
|
<span className="diff-preview-gutter">
|
||||||
|
{line.type === 'removed' && <span className="diff-sign">-</span>}
|
||||||
|
{line.type === 'added' && <span className="diff-sign">+</span>}
|
||||||
|
{line.type === 'unchanged' && <span className="diff-sign"> </span>}
|
||||||
|
</span>
|
||||||
|
<span className="diff-preview-line-num diff-preview-old-num">
|
||||||
|
{line.oldLineNum || ''}
|
||||||
|
</span>
|
||||||
|
<span className="diff-preview-line-num diff-preview-new-num">
|
||||||
|
{line.newLineNum || ''}
|
||||||
|
</span>
|
||||||
|
<span className="diff-preview-code">
|
||||||
|
{line.content || '\u00A0'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useMemo } from 'react';
|
||||||
|
import * as Diff from 'diff';
|
||||||
|
|
||||||
|
interface DiffViewerProps {
|
||||||
|
oldContent: string;
|
||||||
|
newContent: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DiffLine {
|
||||||
|
type: 'added' | 'removed' | 'unchanged';
|
||||||
|
content: string;
|
||||||
|
oldLineNum?: number;
|
||||||
|
newLineNum?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DiffViewer({ oldContent, newContent }: DiffViewerProps) {
|
||||||
|
const diffLines = useMemo(() => {
|
||||||
|
const changes = Diff.diffLines(oldContent, newContent);
|
||||||
|
const lines: DiffLine[] = [];
|
||||||
|
let oldLineNum = 1;
|
||||||
|
let newLineNum = 1;
|
||||||
|
|
||||||
|
for (const change of changes) {
|
||||||
|
const changeLines = change.value.split('\n');
|
||||||
|
// Remove empty last line from split
|
||||||
|
if (changeLines[changeLines.length - 1] === '') {
|
||||||
|
changeLines.pop();
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const line of changeLines) {
|
||||||
|
if (change.added) {
|
||||||
|
lines.push({
|
||||||
|
type: 'added',
|
||||||
|
content: line,
|
||||||
|
newLineNum: newLineNum++,
|
||||||
|
});
|
||||||
|
} else if (change.removed) {
|
||||||
|
lines.push({
|
||||||
|
type: 'removed',
|
||||||
|
content: line,
|
||||||
|
oldLineNum: oldLineNum++,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
lines.push({
|
||||||
|
type: 'unchanged',
|
||||||
|
content: line,
|
||||||
|
oldLineNum: oldLineNum++,
|
||||||
|
newLineNum: newLineNum++,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return lines;
|
||||||
|
}, [oldContent, newContent]);
|
||||||
|
|
||||||
|
// Split into old and new columns
|
||||||
|
const oldLines = diffLines.filter(l => l.type !== 'added');
|
||||||
|
const newLines = diffLines.filter(l => l.type !== 'removed');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="diff-viewer">
|
||||||
|
<div className="diff-header">
|
||||||
|
<div className="diff-column-header">Previous Version</div>
|
||||||
|
<div className="diff-column-header">New Version</div>
|
||||||
|
</div>
|
||||||
|
<div className="diff-content">
|
||||||
|
<div className="diff-column diff-old">
|
||||||
|
{oldLines.map((line, i) => (
|
||||||
|
<div
|
||||||
|
key={i}
|
||||||
|
className={`diff-line ${line.type === 'removed' ? 'diff-removed' : ''}`}
|
||||||
|
>
|
||||||
|
<span className="diff-line-num">{line.oldLineNum || ''}</span>
|
||||||
|
<span className="diff-line-content">{line.content || '\u00A0'}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="diff-column diff-new">
|
||||||
|
{newLines.map((line, i) => (
|
||||||
|
<div
|
||||||
|
key={i}
|
||||||
|
className={`diff-line ${line.type === 'added' ? 'diff-added' : ''}`}
|
||||||
|
>
|
||||||
|
<span className="diff-line-num">{line.newLineNum || ''}</span>
|
||||||
|
<span className="diff-line-content">{line.content || '\u00A0'}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import ReactMarkdown from 'react-markdown';
|
||||||
|
import remarkGfm from 'remark-gfm';
|
||||||
|
|
||||||
|
interface EditorPreviewProps {
|
||||||
|
content: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function EditorPreview({ content }: EditorPreviewProps) {
|
||||||
|
// If content is empty, show placeholder
|
||||||
|
if (!content.trim()) {
|
||||||
|
return (
|
||||||
|
<div className="article-content editor-preview-content editor-preview-empty">
|
||||||
|
<p>Start typing to see the preview...</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="article-content editor-preview-content">
|
||||||
|
<ReactMarkdown remarkPlugins={[remarkGfm]}>
|
||||||
|
{content}
|
||||||
|
</ReactMarkdown>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,375 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState, useRef, useEffect } from 'react';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import {
|
||||||
|
Bold,
|
||||||
|
Italic,
|
||||||
|
Strikethrough,
|
||||||
|
Heading1,
|
||||||
|
Heading2,
|
||||||
|
Heading3,
|
||||||
|
Link,
|
||||||
|
Image,
|
||||||
|
List,
|
||||||
|
ListOrdered,
|
||||||
|
Quote,
|
||||||
|
Code,
|
||||||
|
FileCode,
|
||||||
|
Table,
|
||||||
|
Undo2,
|
||||||
|
Redo2,
|
||||||
|
ChevronDown,
|
||||||
|
Plus,
|
||||||
|
Minus,
|
||||||
|
FileText,
|
||||||
|
AlertTriangle,
|
||||||
|
Info,
|
||||||
|
CheckCircle,
|
||||||
|
} from 'lucide-react';
|
||||||
|
|
||||||
|
interface EditorToolbarProps {
|
||||||
|
onBold: () => void;
|
||||||
|
onItalic: () => void;
|
||||||
|
onStrikethrough: () => void;
|
||||||
|
onHeading: (level: number) => void;
|
||||||
|
onLink: () => void;
|
||||||
|
onImage: () => void;
|
||||||
|
onBulletList: () => void;
|
||||||
|
onNumberedList: () => void;
|
||||||
|
onBlockquote: () => void;
|
||||||
|
onCode: () => void;
|
||||||
|
onCodeBlock: () => void;
|
||||||
|
onTable: () => void;
|
||||||
|
onUndo: () => void;
|
||||||
|
onRedo: () => void;
|
||||||
|
canUndo: boolean;
|
||||||
|
canRedo: boolean;
|
||||||
|
disabled?: boolean;
|
||||||
|
onInsertTemplate?: (template: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const TEMPLATES = {
|
||||||
|
infobox: `> **Note:** This is an informational callout.`,
|
||||||
|
warning: `> **Warning:** This requires attention.`,
|
||||||
|
tip: `> **Tip:** Here's a helpful suggestion.`,
|
||||||
|
divider: `\n---\n`,
|
||||||
|
footnote: `[^1]: Reference text here.`,
|
||||||
|
definition: `**Term**\n: Definition of the term goes here.`,
|
||||||
|
collapse: `<details>\n<summary>Click to expand</summary>\n\nHidden content goes here.\n\n</details>`,
|
||||||
|
};
|
||||||
|
|
||||||
|
export function EditorToolbar({
|
||||||
|
onBold,
|
||||||
|
onItalic,
|
||||||
|
onStrikethrough,
|
||||||
|
onHeading,
|
||||||
|
onLink,
|
||||||
|
onImage,
|
||||||
|
onBulletList,
|
||||||
|
onNumberedList,
|
||||||
|
onBlockquote,
|
||||||
|
onCode,
|
||||||
|
onCodeBlock,
|
||||||
|
onTable,
|
||||||
|
onUndo,
|
||||||
|
onRedo,
|
||||||
|
canUndo,
|
||||||
|
canRedo,
|
||||||
|
disabled = false,
|
||||||
|
onInsertTemplate,
|
||||||
|
}: EditorToolbarProps) {
|
||||||
|
const [showHeadingMenu, setShowHeadingMenu] = useState(false);
|
||||||
|
const [showInsertMenu, setShowInsertMenu] = useState(false);
|
||||||
|
const headingRef = useRef<HTMLDivElement>(null);
|
||||||
|
const insertRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
// Close menus when clicking outside
|
||||||
|
useEffect(() => {
|
||||||
|
const handleClickOutside = (e: MouseEvent) => {
|
||||||
|
if (headingRef.current && !headingRef.current.contains(e.target as Node)) {
|
||||||
|
setShowHeadingMenu(false);
|
||||||
|
}
|
||||||
|
if (insertRef.current && !insertRef.current.contains(e.target as Node)) {
|
||||||
|
setShowInsertMenu(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
document.addEventListener('mousedown', handleClickOutside);
|
||||||
|
return () => document.removeEventListener('mousedown', handleClickOutside);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="editor-toolbar-enhanced">
|
||||||
|
{/* Undo/Redo group */}
|
||||||
|
<div className="toolbar-group">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon-sm"
|
||||||
|
onClick={onUndo}
|
||||||
|
disabled={disabled || !canUndo}
|
||||||
|
title="Undo (Ctrl+Z)"
|
||||||
|
className="toolbar-btn"
|
||||||
|
>
|
||||||
|
<Undo2 className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon-sm"
|
||||||
|
onClick={onRedo}
|
||||||
|
disabled={disabled || !canRedo}
|
||||||
|
title="Redo (Ctrl+Shift+Z)"
|
||||||
|
className="toolbar-btn"
|
||||||
|
>
|
||||||
|
<Redo2 className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="toolbar-divider" />
|
||||||
|
|
||||||
|
{/* Heading dropdown */}
|
||||||
|
<div className="toolbar-group heading-dropdown-container" ref={headingRef}>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setShowHeadingMenu(!showHeadingMenu)}
|
||||||
|
disabled={disabled}
|
||||||
|
className="toolbar-btn heading-btn"
|
||||||
|
>
|
||||||
|
<Heading1 className="w-4 h-4" />
|
||||||
|
<ChevronDown className="w-3 h-3 ml-1" />
|
||||||
|
</Button>
|
||||||
|
{showHeadingMenu && (
|
||||||
|
<div className="heading-dropdown">
|
||||||
|
<button
|
||||||
|
className="heading-option"
|
||||||
|
onClick={() => { onHeading(1); setShowHeadingMenu(false); }}
|
||||||
|
>
|
||||||
|
<Heading1 className="w-5 h-5" />
|
||||||
|
<span>Heading 1</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="heading-option"
|
||||||
|
onClick={() => { onHeading(2); setShowHeadingMenu(false); }}
|
||||||
|
>
|
||||||
|
<Heading2 className="w-5 h-5" />
|
||||||
|
<span>Heading 2</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="heading-option"
|
||||||
|
onClick={() => { onHeading(3); setShowHeadingMenu(false); }}
|
||||||
|
>
|
||||||
|
<Heading3 className="w-5 h-5" />
|
||||||
|
<span>Heading 3</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="toolbar-divider" />
|
||||||
|
|
||||||
|
{/* Text formatting group */}
|
||||||
|
<div className="toolbar-group">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon-sm"
|
||||||
|
onClick={onBold}
|
||||||
|
disabled={disabled}
|
||||||
|
title="Bold (Ctrl+B)"
|
||||||
|
className="toolbar-btn"
|
||||||
|
>
|
||||||
|
<Bold className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon-sm"
|
||||||
|
onClick={onItalic}
|
||||||
|
disabled={disabled}
|
||||||
|
title="Italic (Ctrl+I)"
|
||||||
|
className="toolbar-btn"
|
||||||
|
>
|
||||||
|
<Italic className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon-sm"
|
||||||
|
onClick={onStrikethrough}
|
||||||
|
disabled={disabled}
|
||||||
|
title="Strikethrough"
|
||||||
|
className="toolbar-btn"
|
||||||
|
>
|
||||||
|
<Strikethrough className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="toolbar-divider" />
|
||||||
|
|
||||||
|
{/* Links and media group */}
|
||||||
|
<div className="toolbar-group">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon-sm"
|
||||||
|
onClick={onLink}
|
||||||
|
disabled={disabled}
|
||||||
|
title="Insert Link (Ctrl+K)"
|
||||||
|
className="toolbar-btn"
|
||||||
|
>
|
||||||
|
<Link className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon-sm"
|
||||||
|
onClick={onImage}
|
||||||
|
disabled={disabled}
|
||||||
|
title="Insert Image"
|
||||||
|
className="toolbar-btn"
|
||||||
|
>
|
||||||
|
<Image className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="toolbar-divider" />
|
||||||
|
|
||||||
|
{/* Lists and quotes group */}
|
||||||
|
<div className="toolbar-group">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon-sm"
|
||||||
|
onClick={onBulletList}
|
||||||
|
disabled={disabled}
|
||||||
|
title="Bullet List"
|
||||||
|
className="toolbar-btn"
|
||||||
|
>
|
||||||
|
<List className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon-sm"
|
||||||
|
onClick={onNumberedList}
|
||||||
|
disabled={disabled}
|
||||||
|
title="Numbered List"
|
||||||
|
className="toolbar-btn"
|
||||||
|
>
|
||||||
|
<ListOrdered className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon-sm"
|
||||||
|
onClick={onBlockquote}
|
||||||
|
disabled={disabled}
|
||||||
|
title="Blockquote"
|
||||||
|
className="toolbar-btn"
|
||||||
|
>
|
||||||
|
<Quote className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="toolbar-divider" />
|
||||||
|
|
||||||
|
{/* Code group */}
|
||||||
|
<div className="toolbar-group">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon-sm"
|
||||||
|
onClick={onCode}
|
||||||
|
disabled={disabled}
|
||||||
|
title="Inline Code"
|
||||||
|
className="toolbar-btn"
|
||||||
|
>
|
||||||
|
<Code className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon-sm"
|
||||||
|
onClick={onCodeBlock}
|
||||||
|
disabled={disabled}
|
||||||
|
title="Code Block"
|
||||||
|
className="toolbar-btn"
|
||||||
|
>
|
||||||
|
<FileCode className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="toolbar-divider" />
|
||||||
|
|
||||||
|
{/* Table */}
|
||||||
|
<div className="toolbar-group">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon-sm"
|
||||||
|
onClick={onTable}
|
||||||
|
disabled={disabled}
|
||||||
|
title="Insert Table"
|
||||||
|
className="toolbar-btn"
|
||||||
|
>
|
||||||
|
<Table className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="toolbar-divider" />
|
||||||
|
|
||||||
|
{/* Insert templates menu */}
|
||||||
|
{onInsertTemplate && (
|
||||||
|
<div className="toolbar-group heading-dropdown-container" ref={insertRef}>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setShowInsertMenu(!showInsertMenu)}
|
||||||
|
disabled={disabled}
|
||||||
|
className="toolbar-btn heading-btn"
|
||||||
|
>
|
||||||
|
<Plus className="w-4 h-4" />
|
||||||
|
<span className="text-xs ml-1">Insert</span>
|
||||||
|
<ChevronDown className="w-3 h-3 ml-1" />
|
||||||
|
</Button>
|
||||||
|
{showInsertMenu && (
|
||||||
|
<div className="heading-dropdown insert-dropdown">
|
||||||
|
<button
|
||||||
|
className="heading-option"
|
||||||
|
onClick={() => { onInsertTemplate(TEMPLATES.infobox); setShowInsertMenu(false); }}
|
||||||
|
>
|
||||||
|
<Info className="w-4 h-4 text-blue-500" />
|
||||||
|
<span>Info Box</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="heading-option"
|
||||||
|
onClick={() => { onInsertTemplate(TEMPLATES.warning); setShowInsertMenu(false); }}
|
||||||
|
>
|
||||||
|
<AlertTriangle className="w-4 h-4 text-yellow-500" />
|
||||||
|
<span>Warning Box</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="heading-option"
|
||||||
|
onClick={() => { onInsertTemplate(TEMPLATES.tip); setShowInsertMenu(false); }}
|
||||||
|
>
|
||||||
|
<CheckCircle className="w-4 h-4 text-green-500" />
|
||||||
|
<span>Tip Box</span>
|
||||||
|
</button>
|
||||||
|
<div className="dropdown-divider" />
|
||||||
|
<button
|
||||||
|
className="heading-option"
|
||||||
|
onClick={() => { onInsertTemplate(TEMPLATES.divider); setShowInsertMenu(false); }}
|
||||||
|
>
|
||||||
|
<Minus className="w-4 h-4" />
|
||||||
|
<span>Horizontal Rule</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="heading-option"
|
||||||
|
onClick={() => { onInsertTemplate(TEMPLATES.definition); setShowInsertMenu(false); }}
|
||||||
|
>
|
||||||
|
<FileText className="w-4 h-4" />
|
||||||
|
<span>Definition</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="heading-option"
|
||||||
|
onClick={() => { onInsertTemplate(TEMPLATES.collapse); setShowInsertMenu(false); }}
|
||||||
|
>
|
||||||
|
<ChevronDown className="w-4 h-4" />
|
||||||
|
<span>Collapsible Section</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import { X } from 'lucide-react';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { DiffViewer } from './diff-viewer';
|
||||||
|
|
||||||
|
interface HistoryEntry {
|
||||||
|
id: string;
|
||||||
|
oldContent: string;
|
||||||
|
newContent: string;
|
||||||
|
reason: string;
|
||||||
|
createdAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface HistoryModalProps {
|
||||||
|
slug: string;
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function HistoryModal({ slug, isOpen, onClose }: HistoryModalProps) {
|
||||||
|
const [history, setHistory] = useState<HistoryEntry[]>([]);
|
||||||
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
const [selectedEntry, setSelectedEntry] = useState<HistoryEntry | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isOpen) return;
|
||||||
|
|
||||||
|
const fetchHistory = async () => {
|
||||||
|
setIsLoading(true);
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/article/${encodeURIComponent(slug)}/history`);
|
||||||
|
if (response.ok) {
|
||||||
|
const data = await response.json();
|
||||||
|
setHistory(data);
|
||||||
|
if (data.length > 0) {
|
||||||
|
setSelectedEntry(data[0]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to fetch history:', error);
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
fetchHistory();
|
||||||
|
}, [slug, isOpen]);
|
||||||
|
|
||||||
|
if (!isOpen) return null;
|
||||||
|
|
||||||
|
const formatDate = (dateString: string) => {
|
||||||
|
return new Date(dateString).toLocaleString();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="history-modal-overlay" onClick={onClose}>
|
||||||
|
<div className="history-modal" onClick={(e) => e.stopPropagation()}>
|
||||||
|
<div className="history-modal-header">
|
||||||
|
<h2>Revision History</h2>
|
||||||
|
<Button variant="ghost" size="sm" onClick={onClose}>
|
||||||
|
<X className="w-5 h-5" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="history-modal-body">
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="history-loading">Loading history...</div>
|
||||||
|
) : history.length === 0 ? (
|
||||||
|
<div className="history-empty">No revision history available.</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="history-list">
|
||||||
|
{history.map((entry) => (
|
||||||
|
<button
|
||||||
|
key={entry.id}
|
||||||
|
className={`history-item ${selectedEntry?.id === entry.id ? 'selected' : ''}`}
|
||||||
|
onClick={() => setSelectedEntry(entry)}
|
||||||
|
>
|
||||||
|
<div className="history-item-date">{formatDate(entry.createdAt)}</div>
|
||||||
|
<div className="history-item-reason">{entry.reason}</div>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="history-diff">
|
||||||
|
{selectedEntry && (
|
||||||
|
<DiffViewer
|
||||||
|
oldContent={selectedEntry.oldContent}
|
||||||
|
newContent={selectedEntry.newContent}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useCallback } from 'react';
|
||||||
|
import { useRouter } from 'next/navigation';
|
||||||
|
|
||||||
|
interface PageTransitionProps {
|
||||||
|
children: React.ReactNode;
|
||||||
|
href: string;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PageTransition({ children, href, className }: PageTransitionProps) {
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
const handleClick = useCallback((e: React.MouseEvent<HTMLDivElement>) => {
|
||||||
|
e.preventDefault();
|
||||||
|
router.push(href);
|
||||||
|
}, [href, router]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div onClick={handleClick} className={`inline-block cursor-pointer ${className || ''}`}>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,119 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import Link from 'next/link';
|
||||||
|
import { ArrowRight, Info } from 'lucide-react';
|
||||||
|
import {
|
||||||
|
Tooltip,
|
||||||
|
TooltipContent,
|
||||||
|
TooltipProvider,
|
||||||
|
TooltipTrigger,
|
||||||
|
} from '@/components/ui/tooltip';
|
||||||
|
import type { SearchResult } from '@/app/api/search/route';
|
||||||
|
|
||||||
|
interface RelatedArticlesProps {
|
||||||
|
title: string;
|
||||||
|
currentSlug: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function RelatedArticles({ title, currentSlug }: RelatedArticlesProps) {
|
||||||
|
const [articles, setArticles] = useState<SearchResult[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const fetchRelatedArticles = async () => {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/search?query=${encodeURIComponent(title)}`);
|
||||||
|
if (!response.ok) throw new Error('Failed to fetch related articles');
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
const results = data.results || [];
|
||||||
|
|
||||||
|
// Filter out the current article and take top 5
|
||||||
|
const filtered = results
|
||||||
|
.filter((article: SearchResult) =>
|
||||||
|
article.title.replace(/ /g, '_').toLowerCase() !==
|
||||||
|
currentSlug.toLowerCase()
|
||||||
|
)
|
||||||
|
.slice(0, 5);
|
||||||
|
|
||||||
|
setArticles(filtered);
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : 'An error occurred');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
fetchRelatedArticles();
|
||||||
|
}, [title, currentSlug]);
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<aside className="space-y-4">
|
||||||
|
<h3 className="font-semibold text-foreground flex items-center gap-1.5">
|
||||||
|
Related articles
|
||||||
|
<TooltipProvider>
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<Info className="w-3 h-3 text-muted-foreground cursor-help" />
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>
|
||||||
|
<p>This is currently in beta and may be inaccurate.</p>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</TooltipProvider>
|
||||||
|
</h3>
|
||||||
|
<div className="space-y-3">
|
||||||
|
{[...Array(3)].map((_, i) => (
|
||||||
|
<div key={i} className="animate-pulse">
|
||||||
|
<div className="h-4 bg-muted rounded w-3/4 mb-2"></div>
|
||||||
|
<div className="h-3 bg-muted rounded w-full"></div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error || articles.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<aside className="space-y-4">
|
||||||
|
<h3 className="font-semibold text-foreground flex items-center gap-1.5">
|
||||||
|
Related articles
|
||||||
|
<TooltipProvider>
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<Info className="w-3 h-3 text-muted-foreground cursor-help" />
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>
|
||||||
|
<p>This is currently in beta and may be inaccurate.</p>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</TooltipProvider>
|
||||||
|
</h3>
|
||||||
|
<ul className="space-y-3">
|
||||||
|
{articles.map((article) => {
|
||||||
|
const slug = article.title.replace(/ /g, '_').replace(/%20/g, '_');
|
||||||
|
return (
|
||||||
|
<li key={article.title}>
|
||||||
|
<Link
|
||||||
|
href={`/article/${slug}`}
|
||||||
|
className="group flex items-start gap-2 text-sm hover:opacity-80 transition-opacity"
|
||||||
|
>
|
||||||
|
<ArrowRight className="w-4 h-4 text-muted-foreground mt-0.5 flex-shrink-0 group-hover:text-blue-500 transition-colors" />
|
||||||
|
<span className="text-muted-foreground group-hover:text-foreground transition-colors">
|
||||||
|
{article.title}
|
||||||
|
</span>
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
</aside>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { Check, X } from 'lucide-react';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
|
||||||
|
interface ReviewResultPanelProps {
|
||||||
|
approved: boolean;
|
||||||
|
reason: string;
|
||||||
|
onSave: () => void;
|
||||||
|
onContinueEditing: () => void;
|
||||||
|
isSaving: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ReviewResultPanel({
|
||||||
|
approved,
|
||||||
|
reason,
|
||||||
|
onSave,
|
||||||
|
onContinueEditing,
|
||||||
|
isSaving,
|
||||||
|
}: ReviewResultPanelProps) {
|
||||||
|
if (approved) {
|
||||||
|
return (
|
||||||
|
<div className="review-panel review-approved">
|
||||||
|
<div className="review-icon">
|
||||||
|
<Check className="w-6 h-6" />
|
||||||
|
</div>
|
||||||
|
<div className="review-content">
|
||||||
|
<h3 className="review-title">Changes Approved</h3>
|
||||||
|
<p className="review-reason">{reason}</p>
|
||||||
|
</div>
|
||||||
|
<div className="review-actions">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={onContinueEditing}
|
||||||
|
disabled={isSaving}
|
||||||
|
>
|
||||||
|
Continue Editing
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={onSave}
|
||||||
|
disabled={isSaving}
|
||||||
|
className="bg-green-600 hover:bg-green-700 text-white"
|
||||||
|
>
|
||||||
|
{isSaving ? 'Saving...' : 'Save Changes'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="review-panel review-rejected">
|
||||||
|
<div className="review-icon">
|
||||||
|
<X className="w-6 h-6" />
|
||||||
|
</div>
|
||||||
|
<div className="review-content">
|
||||||
|
<h3 className="review-title">Changes Not Approved</h3>
|
||||||
|
<p className="review-reason">{reason}</p>
|
||||||
|
</div>
|
||||||
|
<div className="review-actions">
|
||||||
|
<Button onClick={onContinueEditing}>
|
||||||
|
Continue Editing
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,113 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState, useEffect, useCallback } from 'react';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
import type { TocItem } from './article-stream';
|
||||||
|
|
||||||
|
interface TableOfContentsProps {
|
||||||
|
headings: TocItem[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TableOfContents({ headings }: TableOfContentsProps) {
|
||||||
|
const [activeId, setActiveId] = useState<string>('');
|
||||||
|
|
||||||
|
const updateActiveHeading = useCallback(() => {
|
||||||
|
if (headings.length === 0) return;
|
||||||
|
|
||||||
|
const headingElements = headings
|
||||||
|
.map((h) => ({
|
||||||
|
id: h.id,
|
||||||
|
element: document.getElementById(h.id),
|
||||||
|
}))
|
||||||
|
.filter((h) => h.element !== null);
|
||||||
|
|
||||||
|
if (headingElements.length === 0) return;
|
||||||
|
|
||||||
|
// Find the heading that's closest to the top of the viewport but still visible or just passed
|
||||||
|
const scrollPosition = window.scrollY + 120; // Account for sticky header
|
||||||
|
|
||||||
|
let currentHeading = headingElements[0].id;
|
||||||
|
|
||||||
|
for (const { id, element } of headingElements) {
|
||||||
|
if (element) {
|
||||||
|
const elementTop = element.offsetTop;
|
||||||
|
if (elementTop <= scrollPosition) {
|
||||||
|
currentHeading = id;
|
||||||
|
} else {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setActiveId(currentHeading);
|
||||||
|
}, [headings]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// Initial update
|
||||||
|
updateActiveHeading();
|
||||||
|
|
||||||
|
// Throttled scroll handler
|
||||||
|
let ticking = false;
|
||||||
|
const handleScroll = () => {
|
||||||
|
if (!ticking) {
|
||||||
|
window.requestAnimationFrame(() => {
|
||||||
|
updateActiveHeading();
|
||||||
|
ticking = false;
|
||||||
|
});
|
||||||
|
ticking = true;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener('scroll', handleScroll, { passive: true });
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('scroll', handleScroll);
|
||||||
|
};
|
||||||
|
}, [updateActiveHeading]);
|
||||||
|
|
||||||
|
const handleClick = (e: React.MouseEvent<HTMLAnchorElement>, id: string) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const element = document.getElementById(id);
|
||||||
|
if (element) {
|
||||||
|
const yOffset = -100;
|
||||||
|
const y = element.getBoundingClientRect().top + window.pageYOffset + yOffset;
|
||||||
|
window.scrollTo({ top: y, behavior: 'smooth' });
|
||||||
|
setActiveId(id);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (headings.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<nav className="sticky top-24">
|
||||||
|
<h4 className="text-sm font-semibold text-foreground mb-4">
|
||||||
|
On this page
|
||||||
|
</h4>
|
||||||
|
<ul className="space-y-1 text-sm border-l border-border">
|
||||||
|
{headings.map((heading) => (
|
||||||
|
<li
|
||||||
|
key={heading.id}
|
||||||
|
className={cn(
|
||||||
|
heading.level === 3 && 'ml-3'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<a
|
||||||
|
href={`#${heading.id}`}
|
||||||
|
onClick={(e) => handleClick(e, heading.id)}
|
||||||
|
className={cn(
|
||||||
|
'block py-1.5 pl-4 -ml-px border-l-2 transition-colors',
|
||||||
|
activeId === heading.id
|
||||||
|
? 'border-blue-500 text-foreground font-medium'
|
||||||
|
: 'border-transparent text-muted-foreground hover:text-foreground hover:border-muted-foreground'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{heading.text}
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</nav>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
import { Slot } from "@radix-ui/react-slot"
|
||||||
|
import { cva, type VariantProps } from "class-variance-authority"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const buttonVariants = cva(
|
||||||
|
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
default: "bg-primary text-primary-foreground hover:bg-primary/90",
|
||||||
|
destructive:
|
||||||
|
"bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
|
||||||
|
outline:
|
||||||
|
"border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50",
|
||||||
|
secondary:
|
||||||
|
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
||||||
|
ghost:
|
||||||
|
"hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
|
||||||
|
link: "text-primary underline-offset-4 hover:underline",
|
||||||
|
},
|
||||||
|
size: {
|
||||||
|
default: "h-9 px-4 py-2 has-[>svg]:px-3",
|
||||||
|
xs: "h-6 gap-1 rounded-md px-2 text-xs has-[>svg]:px-1.5 [&_svg:not([class*='size-'])]:size-3",
|
||||||
|
sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5",
|
||||||
|
lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
|
||||||
|
icon: "size-9",
|
||||||
|
"icon-xs": "size-6 rounded-md [&_svg:not([class*='size-'])]:size-3",
|
||||||
|
"icon-sm": "size-8",
|
||||||
|
"icon-lg": "size-10",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
variant: "default",
|
||||||
|
size: "default",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
function Button({
|
||||||
|
className,
|
||||||
|
variant = "default",
|
||||||
|
size = "default",
|
||||||
|
asChild = false,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"button"> &
|
||||||
|
VariantProps<typeof buttonVariants> & {
|
||||||
|
asChild?: boolean
|
||||||
|
}) {
|
||||||
|
const Comp = asChild ? Slot : "button"
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Comp
|
||||||
|
data-slot="button"
|
||||||
|
data-variant={variant}
|
||||||
|
data-size={size}
|
||||||
|
className={cn(buttonVariants({ variant, size, className }))}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Button, buttonVariants }
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
function Card({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="card"
|
||||||
|
className={cn(
|
||||||
|
"bg-card text-card-foreground flex flex-col gap-6 rounded-xl border py-6 shadow-sm",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="card-header"
|
||||||
|
className={cn(
|
||||||
|
"@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-2 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="card-title"
|
||||||
|
className={cn("leading-none font-semibold", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="card-description"
|
||||||
|
className={cn("text-muted-foreground text-sm", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CardAction({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="card-action"
|
||||||
|
className={cn(
|
||||||
|
"col-start-2 row-span-2 row-start-1 self-start justify-self-end",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="card-content"
|
||||||
|
className={cn("px-6", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="card-footer"
|
||||||
|
className={cn("flex items-center px-6 [.border-t]:pt-6", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
Card,
|
||||||
|
CardHeader,
|
||||||
|
CardFooter,
|
||||||
|
CardTitle,
|
||||||
|
CardAction,
|
||||||
|
CardDescription,
|
||||||
|
CardContent,
|
||||||
|
}
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import * as TooltipPrimitive from "@radix-ui/react-tooltip"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
function TooltipProvider({
|
||||||
|
delayDuration = 0,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof TooltipPrimitive.Provider>) {
|
||||||
|
return (
|
||||||
|
<TooltipPrimitive.Provider
|
||||||
|
data-slot="tooltip-provider"
|
||||||
|
delayDuration={delayDuration}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function Tooltip({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof TooltipPrimitive.Root>) {
|
||||||
|
return (
|
||||||
|
<TooltipProvider>
|
||||||
|
<TooltipPrimitive.Root data-slot="tooltip" {...props} />
|
||||||
|
</TooltipProvider>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function TooltipTrigger({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof TooltipPrimitive.Trigger>) {
|
||||||
|
return <TooltipPrimitive.Trigger data-slot="tooltip-trigger" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function TooltipContent({
|
||||||
|
className,
|
||||||
|
sideOffset = 0,
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof TooltipPrimitive.Content>) {
|
||||||
|
return (
|
||||||
|
<TooltipPrimitive.Portal>
|
||||||
|
<TooltipPrimitive.Content
|
||||||
|
data-slot="tooltip-content"
|
||||||
|
sideOffset={sideOffset}
|
||||||
|
className={cn(
|
||||||
|
"bg-foreground text-background animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-fit origin-(--radix-tooltip-content-transform-origin) rounded-md px-3 py-1.5 text-xs text-balance",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
<TooltipPrimitive.Arrow className="bg-foreground fill-foreground z-50 size-2.5 translate-y-[calc(-50%_-_2px)] rotate-45 rounded-[2px]" />
|
||||||
|
</TooltipPrimitive.Content>
|
||||||
|
</TooltipPrimitive.Portal>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
|
||||||
|
interface WikiContentProps {
|
||||||
|
html: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function WikiContent({ html }: WikiContentProps) {
|
||||||
|
const [mounted, setMounted] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setMounted(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
if (!mounted) {
|
||||||
|
return (
|
||||||
|
<div className="animate-pulse space-y-4">
|
||||||
|
<div className="h-4 bg-muted rounded w-full" />
|
||||||
|
<div className="h-4 bg-muted rounded w-5/6" />
|
||||||
|
<div className="h-4 bg-muted rounded w-4/6" />
|
||||||
|
<div className="h-4 bg-muted rounded w-full" />
|
||||||
|
<div className="h-4 bg-muted rounded w-3/6" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="wiki-content"
|
||||||
|
dangerouslySetInnerHTML={{ __html: html }}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import { PrismaClient } from '@prisma/client';
|
||||||
|
import { Pool } from 'pg';
|
||||||
|
import { PrismaPg } from '@prisma/adapter-pg';
|
||||||
|
|
||||||
|
const globalForPrisma = globalThis as unknown as {
|
||||||
|
prisma: PrismaClient | undefined;
|
||||||
|
};
|
||||||
|
|
||||||
|
function createPrismaClient() {
|
||||||
|
const pool = new Pool({ connectionString: process.env.DATABASE_URL });
|
||||||
|
const adapter = new PrismaPg(pool);
|
||||||
|
return new PrismaClient({ adapter });
|
||||||
|
}
|
||||||
|
|
||||||
|
export const prisma = globalForPrisma.prisma ?? createPrismaClient();
|
||||||
|
|
||||||
|
if (process.env.NODE_ENV !== 'production') {
|
||||||
|
globalForPrisma.prisma = prisma;
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
export interface WikipediaTopic {
|
||||||
|
title: string;
|
||||||
|
extract: string;
|
||||||
|
imageUrl?: string;
|
||||||
|
pageUrl: string;
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
import { clsx, type ClassValue } from "clsx"
|
||||||
|
import { twMerge } from "tailwind-merge"
|
||||||
|
|
||||||
|
export function cn(...inputs: ClassValue[]) {
|
||||||
|
return twMerge(clsx(inputs))
|
||||||
|
}
|
||||||
+12
-1
@@ -1,7 +1,18 @@
|
|||||||
import type { NextConfig } from "next";
|
import type { NextConfig } from "next";
|
||||||
|
|
||||||
const nextConfig: NextConfig = {
|
const nextConfig: NextConfig = {
|
||||||
/* config options here */
|
images: {
|
||||||
|
remotePatterns: [
|
||||||
|
{
|
||||||
|
protocol: 'https',
|
||||||
|
hostname: 'upload.wikimedia.org',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
protocol: 'https',
|
||||||
|
hostname: '*.wikipedia.org',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
export default nextConfig;
|
export default nextConfig;
|
||||||
|
|||||||
Generated
+3126
-16
File diff suppressed because it is too large
Load Diff
+21
-1
@@ -9,18 +9,38 @@
|
|||||||
"lint": "eslint"
|
"lint": "eslint"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@fortawesome/fontawesome-svg-core": "^7.1.0",
|
||||||
|
"@fortawesome/free-solid-svg-icons": "^7.1.0",
|
||||||
|
"@fortawesome/react-fontawesome": "^3.1.1",
|
||||||
|
"@prisma/adapter-pg": "^7.3.0",
|
||||||
|
"@prisma/client": "^7.3.0",
|
||||||
|
"@radix-ui/react-slot": "^1.2.4",
|
||||||
|
"@radix-ui/react-tooltip": "^1.2.8",
|
||||||
|
"class-variance-authority": "^0.7.1",
|
||||||
|
"clsx": "^2.1.1",
|
||||||
|
"diff": "^8.0.3",
|
||||||
|
"dotenv": "^17.2.3",
|
||||||
|
"lucide-react": "^0.563.0",
|
||||||
"next": "16.1.4",
|
"next": "16.1.4",
|
||||||
|
"pg": "^8.17.2",
|
||||||
|
"prisma": "^7.3.0",
|
||||||
"react": "19.2.3",
|
"react": "19.2.3",
|
||||||
"react-dom": "19.2.3"
|
"react-dom": "19.2.3",
|
||||||
|
"react-markdown": "^10.1.0",
|
||||||
|
"remark-gfm": "^4.0.1",
|
||||||
|
"tailwind-merge": "^3.4.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@tailwindcss/postcss": "^4",
|
"@tailwindcss/postcss": "^4",
|
||||||
|
"@types/diff": "^7.0.2",
|
||||||
"@types/node": "^20",
|
"@types/node": "^20",
|
||||||
|
"@types/pg": "^8.16.0",
|
||||||
"@types/react": "^19",
|
"@types/react": "^19",
|
||||||
"@types/react-dom": "^19",
|
"@types/react-dom": "^19",
|
||||||
"eslint": "^9",
|
"eslint": "^9",
|
||||||
"eslint-config-next": "16.1.4",
|
"eslint-config-next": "16.1.4",
|
||||||
"tailwindcss": "^4",
|
"tailwindcss": "^4",
|
||||||
|
"tw-animate-css": "^1.4.0",
|
||||||
"typescript": "^5"
|
"typescript": "^5"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import path from 'node:path';
|
||||||
|
import { defineConfig } from 'prisma/config';
|
||||||
|
import 'dotenv/config';
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
schema: path.join(__dirname, 'prisma', 'schema.prisma'),
|
||||||
|
datasource: {
|
||||||
|
url: process.env.DATABASE_URL!,
|
||||||
|
},
|
||||||
|
migrate: {
|
||||||
|
adapter: async () => {
|
||||||
|
const { Pool } = await import('pg');
|
||||||
|
const { PrismaPg } = await import('@prisma/adapter-pg');
|
||||||
|
|
||||||
|
const pool = new Pool({ connectionString: process.env.DATABASE_URL });
|
||||||
|
return new PrismaPg(pool);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "Article" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"slug" TEXT NOT NULL,
|
||||||
|
"title" TEXT NOT NULL,
|
||||||
|
"content" TEXT NOT NULL,
|
||||||
|
"imageUrl" TEXT,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "Article_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "Article_slug_key" ON "Article"("slug");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "Article_slug_idx" ON "Article"("slug");
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "ArticleHistory" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"articleId" TEXT NOT NULL,
|
||||||
|
"oldContent" TEXT NOT NULL,
|
||||||
|
"newContent" TEXT NOT NULL,
|
||||||
|
"reason" TEXT NOT NULL,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
|
||||||
|
CONSTRAINT "ArticleHistory_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "ArticleHistory_articleId_idx" ON "ArticleHistory"("articleId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "ArticleHistory_createdAt_idx" ON "ArticleHistory"("createdAt");
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "ArticleHistory" ADD CONSTRAINT "ArticleHistory_articleId_fkey" FOREIGN KEY ("articleId") REFERENCES "Article"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "Search" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"query" TEXT NOT NULL,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
|
||||||
|
CONSTRAINT "Search_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "Search_createdAt_idx" ON "Search"("createdAt");
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
# Please do not edit this file manually
|
||||||
|
# It should be added in your version-control system (e.g., Git)
|
||||||
|
provider = "postgresql"
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
// This is your Prisma schema file,
|
||||||
|
// learn more about it in the docs: https://pris.ly/d/prisma-schema
|
||||||
|
|
||||||
|
generator client {
|
||||||
|
provider = "prisma-client-js"
|
||||||
|
}
|
||||||
|
|
||||||
|
datasource db {
|
||||||
|
provider = "postgresql"
|
||||||
|
}
|
||||||
|
|
||||||
|
model Article {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
slug String @unique // URL-friendly title (e.g., "Albert_Einstein")
|
||||||
|
title String // Display title
|
||||||
|
content String @db.Text // Full markdown content
|
||||||
|
imageUrl String? // Main image URL
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
history ArticleHistory[]
|
||||||
|
|
||||||
|
@@index([slug])
|
||||||
|
}
|
||||||
|
|
||||||
|
model ArticleHistory {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
articleId String
|
||||||
|
article Article @relation(fields: [articleId], references: [id], onDelete: Cascade)
|
||||||
|
oldContent String @db.Text
|
||||||
|
newContent String @db.Text
|
||||||
|
reason String // AI reason or "Initial article"
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
|
||||||
|
@@index([articleId])
|
||||||
|
@@index([createdAt])
|
||||||
|
}
|
||||||
|
|
||||||
|
model Search {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
query String
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
|
||||||
|
@@index([createdAt])
|
||||||
|
}
|
||||||
+1
-1
@@ -30,5 +30,5 @@
|
|||||||
".next/dev/types/**/*.ts",
|
".next/dev/types/**/*.ts",
|
||||||
"**/*.mts"
|
"**/*.mts"
|
||||||
],
|
],
|
||||||
"exclude": ["node_modules"]
|
"exclude": ["node_modules", "prisma.config.ts"]
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user