Major changes
This commit is contained in:
@@ -39,3 +39,5 @@ yarn-error.log*
|
||||
# typescript
|
||||
*.tsbuildinfo
|
||||
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>
|
||||
);
|
||||
}
|
||||
+1442
-11
File diff suppressed because it is too large
Load Diff
+7
-3
@@ -1,6 +1,10 @@
|
||||
import type { Metadata } from "next";
|
||||
import { Geist, Geist_Mono } from "next/font/google";
|
||||
import "./globals.css";
|
||||
import { config } from '@fortawesome/fontawesome-svg-core';
|
||||
import '@fortawesome/fontawesome-svg-core/styles.css';
|
||||
|
||||
config.autoAddCss = false;
|
||||
|
||||
const geistSans = Geist({
|
||||
variable: "--font-geist-sans",
|
||||
@@ -13,8 +17,8 @@ const geistMono = Geist_Mono({
|
||||
});
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Create Next App",
|
||||
description: "Generated by create next app",
|
||||
title: "Wiki+",
|
||||
description: "Discover random knowledge from Wikipedia",
|
||||
};
|
||||
|
||||
export default function RootLayout({
|
||||
@@ -23,7 +27,7 @@ export default function RootLayout({
|
||||
children: React.ReactNode;
|
||||
}>) {
|
||||
return (
|
||||
<html lang="en">
|
||||
<html lang="en" className="dark">
|
||||
<body
|
||||
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
|
||||
>
|
||||
|
||||
+54
-54
@@ -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() {
|
||||
const [query, setQuery] = useState('');
|
||||
const router = useRouter();
|
||||
|
||||
const handleSearch = (e: FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (query.trim()) {
|
||||
router.push(`/search?query=${encodeURIComponent(query.trim())}`);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center bg-zinc-50 font-sans dark:bg-black">
|
||||
<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">
|
||||
<Image
|
||||
className="dark:invert"
|
||||
src="/next.svg"
|
||||
alt="Next.js logo"
|
||||
width={100}
|
||||
height={20}
|
||||
priority
|
||||
/>
|
||||
<div className="flex flex-col items-center gap-6 text-center sm:items-start sm:text-left">
|
||||
<h1 className="max-w-xs text-3xl font-semibold leading-10 tracking-tight text-black dark:text-zinc-50">
|
||||
To get started, edit the page.tsx file.
|
||||
<main className="min-h-screen flex flex-col items-center justify-center px-6">
|
||||
<div className="w-full max-w-xl text-center">
|
||||
<h1 className="text-5xl sm:text-6xl font-semibold tracking-tight text-foreground mb-3">
|
||||
Wiki<span className="text-neutral-400">+</span>
|
||||
</h1>
|
||||
<p className="max-w-md text-lg leading-8 text-zinc-600 dark:text-zinc-400">
|
||||
Looking for a starting point or more instructions? Head over to{" "}
|
||||
<a
|
||||
href="https://vercel.com/templates?framework=next.js&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"
|
||||
>
|
||||
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 className="text-muted-foreground mb-10">
|
||||
Search the encyclopedia
|
||||
</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}
|
||||
|
||||
<form onSubmit={handleSearch} className="w-full mb-6">
|
||||
<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">
|
||||
<Search className="w-5 h-5 text-muted-foreground mr-3 flex-shrink-0" />
|
||||
<input
|
||||
type="text"
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
placeholder="Search for anything..."
|
||||
className="flex-1 bg-transparent outline-none text-foreground placeholder:text-muted-foreground"
|
||||
autoFocus
|
||||
/>
|
||||
Deploy Now
|
||||
</a>
|
||||
<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]"
|
||||
href="https://nextjs.org/docs?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div className="flex gap-3 justify-center">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleSearch}
|
||||
className="px-5 py-2.5 text-sm font-medium bg-foreground text-background rounded-lg hover:opacity-90 transition-opacity"
|
||||
>
|
||||
Documentation
|
||||
</a>
|
||||
Search
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
/* config options here */
|
||||
images: {
|
||||
remotePatterns: [
|
||||
{
|
||||
protocol: 'https',
|
||||
hostname: 'upload.wikimedia.org',
|
||||
},
|
||||
{
|
||||
protocol: 'https',
|
||||
hostname: '*.wikipedia.org',
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
export default nextConfig;
|
||||
|
||||
Generated
+3126
-16
File diff suppressed because it is too large
Load Diff
+21
-1
@@ -9,18 +9,38 @@
|
||||
"lint": "eslint"
|
||||
},
|
||||
"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",
|
||||
"pg": "^8.17.2",
|
||||
"prisma": "^7.3.0",
|
||||
"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": {
|
||||
"@tailwindcss/postcss": "^4",
|
||||
"@types/diff": "^7.0.2",
|
||||
"@types/node": "^20",
|
||||
"@types/pg": "^8.16.0",
|
||||
"@types/react": "^19",
|
||||
"@types/react-dom": "^19",
|
||||
"eslint": "^9",
|
||||
"eslint-config-next": "16.1.4",
|
||||
"tailwindcss": "^4",
|
||||
"tw-animate-css": "^1.4.0",
|
||||
"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",
|
||||
"**/*.mts"
|
||||
],
|
||||
"exclude": ["node_modules"]
|
||||
"exclude": ["node_modules", "prisma.config.ts"]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user