Major changes
This commit is contained in:
@@ -0,0 +1,252 @@
|
||||
import { NextRequest } from 'next/server';
|
||||
import { prisma } from '@/lib/db';
|
||||
|
||||
interface WikipediaSummary {
|
||||
title: string;
|
||||
extract: string;
|
||||
thumbnail?: { source: string };
|
||||
originalimage?: { source: string };
|
||||
}
|
||||
|
||||
interface WikiParseResult {
|
||||
parse: {
|
||||
title: string;
|
||||
text: { '*': string };
|
||||
images: string[];
|
||||
};
|
||||
}
|
||||
|
||||
async function getWikipediaSummary(title: string): Promise<WikipediaSummary | null> {
|
||||
try {
|
||||
const apiTitle = title.replace(/%20/g, '_');
|
||||
const response = await fetch(
|
||||
`https://en.wikipedia.org/api/rest_v1/page/summary/${apiTitle}`,
|
||||
{
|
||||
headers: { 'User-Agent': 'WikiPlus/1.0' },
|
||||
}
|
||||
);
|
||||
if (!response.ok) return null;
|
||||
return await response.json();
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function getWikipediaContent(title: string): Promise<{ text: string; images: string[] } | null> {
|
||||
try {
|
||||
const apiTitle = title.replace(/%20/g, '_');
|
||||
const response = await fetch(
|
||||
`https://en.wikipedia.org/w/api.php?action=parse&page=${encodeURIComponent(apiTitle)}&format=json&prop=text|images&disableeditsection=true&redirects=true`,
|
||||
{
|
||||
headers: { 'User-Agent': 'WikiPlus/1.0' },
|
||||
}
|
||||
);
|
||||
if (!response.ok) return null;
|
||||
const data: WikiParseResult = await response.json();
|
||||
if (!data.parse) return null;
|
||||
|
||||
// Extract plain text from HTML
|
||||
const html = data.parse.text?.['*'] || '';
|
||||
const plainText = html
|
||||
.replace(/<style[^>]*>[\s\S]*?<\/style>/gi, '')
|
||||
.replace(/<script[^>]*>[\s\S]*?<\/script>/gi, '')
|
||||
.replace(/<[^>]+>/g, ' ')
|
||||
.replace(/ /g, ' ')
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, "'")
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim();
|
||||
|
||||
// Get image URLs
|
||||
const images = (data.parse.images || [])
|
||||
.filter((img: string) => !img.includes('icon') && !img.includes('logo') && !img.includes('Commons-logo'))
|
||||
.slice(0, 5)
|
||||
.map((img: string) => `https://en.wikipedia.org/wiki/Special:FilePath/${encodeURIComponent(img)}`);
|
||||
|
||||
return { text: plainText.slice(0, 15000), images };
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const searchParams = request.nextUrl.searchParams;
|
||||
const title = searchParams.get('title');
|
||||
|
||||
if (!title) {
|
||||
return new Response('Missing title parameter', { status: 400 });
|
||||
}
|
||||
|
||||
// Normalize the slug for database lookup
|
||||
const slug = title.replace(/%20/g, '_').replace(/ /g, '_');
|
||||
|
||||
// Check if article exists in database
|
||||
const existingArticle = await prisma.article.findUnique({
|
||||
where: { slug },
|
||||
});
|
||||
|
||||
if (existingArticle) {
|
||||
// Return cached article wrapped in <article> tags
|
||||
const cachedContent = `<article>${existingArticle.content}</article>`;
|
||||
return new Response(cachedContent, {
|
||||
headers: {
|
||||
'Content-Type': 'text/plain; charset=utf-8',
|
||||
'X-Cache': 'HIT',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const apiKey = process.env.OPENROUTER_API_KEY;
|
||||
if (!apiKey) {
|
||||
return new Response('OpenRouter API key not configured', { status: 500 });
|
||||
}
|
||||
|
||||
// Fetch Wikipedia data
|
||||
const [summary, content] = await Promise.all([
|
||||
getWikipediaSummary(title),
|
||||
getWikipediaContent(title),
|
||||
]);
|
||||
|
||||
if (!summary || !content) {
|
||||
return new Response('Article not found', { status: 404 });
|
||||
}
|
||||
|
||||
const imageUrl = summary.thumbnail?.source || summary.originalimage?.source;
|
||||
const imageContext = imageUrl
|
||||
? `\n\nMain image available: ${imageUrl}\nAdditional images: ${content.images.join(', ')}`
|
||||
: content.images.length > 0
|
||||
? `\n\nImages available: ${content.images.join(', ')}`
|
||||
: '';
|
||||
|
||||
const systemPrompt = `You are an expert writer creating comprehensive, in-depth articles for an AI-powered Wikipedia alternative. Your goal is to transform encyclopedia content into rich, detailed, and engaging prose that thoroughly covers the topic.
|
||||
|
||||
Guidelines:
|
||||
- Write a COMPREHENSIVE and DETAILED article - aim for depth and thoroughness
|
||||
- Cover ALL major aspects of the topic: history, significance, key details, related concepts, and impact
|
||||
- Use markdown formatting extensively:
|
||||
- Use ## for main sections and ### for subsections
|
||||
- Use **bold** for key terms and *italic* for emphasis
|
||||
- Use bullet lists and numbered lists where appropriate
|
||||
- Use > blockquotes for notable quotes or key facts
|
||||
- Include the main image at the top using markdown: 
|
||||
- Structure the article with multiple well-developed sections (5-8 sections minimum)
|
||||
- Each section should have multiple paragraphs with detailed explanations
|
||||
- Add context that helps readers understand why this topic matters
|
||||
- Include interesting facts, historical context, and connections to broader themes
|
||||
- Maintain factual accuracy - expand on the source material but don't invent facts
|
||||
- Write at least 1000-1500 words for a thorough treatment of the topic
|
||||
- Output ONLY the article content wrapped in <article></article> tags
|
||||
- Do not include any text outside the <article> tags`;
|
||||
|
||||
const userPrompt = `Write a comprehensive, detailed article about "${summary.title}" based on this Wikipedia content:
|
||||
|
||||
Summary: ${summary.extract}
|
||||
|
||||
Full content: ${content.text}${imageContext}
|
||||
|
||||
Requirements:
|
||||
- Write a THOROUGH article with 5-8 well-developed sections minimum
|
||||
- Each section should have multiple detailed paragraphs
|
||||
- Cover history, significance, key facts, and broader context
|
||||
- Use rich markdown formatting throughout
|
||||
- Aim for 1000-1500+ words total
|
||||
- Output in markdown wrapped in <article></article> tags`;
|
||||
|
||||
// Call OpenRouter API with streaming
|
||||
const openRouterResponse = await fetch('https://openrouter.ai/api/v1/chat/completions', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${apiKey}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model: 'minimax/minimax-m2.1',
|
||||
messages: [
|
||||
{ role: 'system', content: systemPrompt },
|
||||
{ role: 'user', content: userPrompt },
|
||||
],
|
||||
stream: true,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!openRouterResponse.ok) {
|
||||
const error = await openRouterResponse.text();
|
||||
console.error('OpenRouter error:', error);
|
||||
return new Response('Failed to generate article', { status: 500 });
|
||||
}
|
||||
|
||||
// Transform the OpenRouter SSE stream to extract content and save to DB
|
||||
const encoder = new TextEncoder();
|
||||
const decoder = new TextDecoder();
|
||||
let fullContent = '';
|
||||
|
||||
const transformStream = new TransformStream({
|
||||
async transform(chunk, controller) {
|
||||
const text = decoder.decode(chunk);
|
||||
const lines = text.split('\n');
|
||||
|
||||
for (const line of lines) {
|
||||
if (line.startsWith('data: ')) {
|
||||
const data = line.slice(6);
|
||||
if (data === '[DONE]') {
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
const parsed = JSON.parse(data);
|
||||
const contentChunk = parsed.choices?.[0]?.delta?.content;
|
||||
if (contentChunk) {
|
||||
fullContent += contentChunk;
|
||||
controller.enqueue(encoder.encode(contentChunk));
|
||||
}
|
||||
} catch {
|
||||
// Skip invalid JSON
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
async flush() {
|
||||
// Extract content between <article> tags and save to database
|
||||
const articleMatch = fullContent.match(/<article>([\s\S]*?)<\/article>/);
|
||||
const articleContent = articleMatch ? articleMatch[1].trim() : fullContent;
|
||||
|
||||
if (articleContent) {
|
||||
try {
|
||||
const newArticle = await prisma.article.create({
|
||||
data: {
|
||||
slug,
|
||||
title: summary.title,
|
||||
content: articleContent,
|
||||
imageUrl: imageUrl || null,
|
||||
},
|
||||
});
|
||||
|
||||
// Create initial history entry
|
||||
await prisma.articleHistory.create({
|
||||
data: {
|
||||
articleId: newArticle.id,
|
||||
oldContent: '',
|
||||
newContent: articleContent,
|
||||
reason: 'Initial article',
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to save article to database:', error);
|
||||
}
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
const stream = openRouterResponse.body?.pipeThrough(transformStream);
|
||||
|
||||
return new Response(stream, {
|
||||
headers: {
|
||||
'Content-Type': 'text/plain; charset=utf-8',
|
||||
'Transfer-Encoding': 'chunked',
|
||||
'Cache-Control': 'no-cache',
|
||||
'X-Cache': 'MISS',
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { prisma } from '@/lib/db';
|
||||
|
||||
export async function GET(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ slug: string }> }
|
||||
) {
|
||||
const { slug } = await params;
|
||||
|
||||
// Fetch article to get its ID
|
||||
const article = await prisma.article.findUnique({
|
||||
where: { slug },
|
||||
select: { id: true },
|
||||
});
|
||||
|
||||
if (!article) {
|
||||
return NextResponse.json({ error: 'Article not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
// Fetch history entries
|
||||
const history = await prisma.articleHistory.findMany({
|
||||
where: { articleId: article.id },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
select: {
|
||||
id: true,
|
||||
oldContent: true,
|
||||
newContent: true,
|
||||
reason: true,
|
||||
createdAt: true,
|
||||
},
|
||||
});
|
||||
|
||||
return NextResponse.json(history);
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
import { NextRequest } from 'next/server';
|
||||
import { prisma } from '@/lib/db';
|
||||
import * as Diff from 'diff';
|
||||
|
||||
function generateDiffText(oldContent: string, newContent: string): string {
|
||||
const changes = Diff.diffLines(oldContent, newContent);
|
||||
const diffLines: string[] = [];
|
||||
|
||||
for (const change of changes) {
|
||||
const lines = change.value.split('\n').filter(line => line !== '');
|
||||
for (const line of lines) {
|
||||
if (change.added) {
|
||||
diffLines.push(`+ ${line}`);
|
||||
} else if (change.removed) {
|
||||
diffLines.push(`- ${line}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return diffLines.join('\n');
|
||||
}
|
||||
|
||||
export async function POST(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ slug: string }> }
|
||||
) {
|
||||
const { slug } = await params;
|
||||
|
||||
const body = await request.json();
|
||||
const { newContent } = body;
|
||||
|
||||
if (!newContent || typeof newContent !== 'string') {
|
||||
return new Response('Missing newContent', { status: 400 });
|
||||
}
|
||||
|
||||
// Fetch current article
|
||||
const article = await prisma.article.findUnique({
|
||||
where: { slug },
|
||||
});
|
||||
|
||||
if (!article) {
|
||||
return new Response('Article not found', { status: 404 });
|
||||
}
|
||||
|
||||
const apiKey = process.env.OPENROUTER_API_KEY;
|
||||
if (!apiKey) {
|
||||
return new Response('OpenRouter API key not configured', { status: 500 });
|
||||
}
|
||||
|
||||
// Generate diff between old and new content
|
||||
const diffText = generateDiffText(article.content, newContent);
|
||||
|
||||
const systemPrompt = `You are an article edit reviewer. Your job is to evaluate proposed changes (diffs) to articles.
|
||||
|
||||
You will be given:
|
||||
1. The current article content (assume this is factually correct and the source of truth)
|
||||
2. A diff showing the proposed changes (lines starting with "-" are removed, lines starting with "+" are added)
|
||||
|
||||
Evaluate ONLY the changes based on:
|
||||
1. **Consistency** - Do the edits contradict or conflict with the existing article content?
|
||||
2. **Quality** - Is the new/modified text well-written and grammatically correct?
|
||||
3. **Relevance** - Are the changes relevant to the article topic?
|
||||
4. **Appropriateness** - Is the content appropriate (no spam, vandalism, offensive content)?
|
||||
|
||||
Do NOT fact-check against external knowledge. Assume the original article is correct and evaluate whether the edits are consistent with it.
|
||||
|
||||
After your evaluation, you MUST output your decision in this exact format:
|
||||
<action approve="true" reason="Your brief reason for approval" />
|
||||
OR
|
||||
<action approve="false" reason="Your brief reason for rejection" />
|
||||
|
||||
Be constructive in your feedback. If rejecting, explain what could be improved.`;
|
||||
|
||||
const userPrompt = `Please review the following article edit:
|
||||
|
||||
**Current Article:**
|
||||
${article.content.slice(0, 6000)}
|
||||
|
||||
**Proposed Changes (diff):**
|
||||
\`\`\`diff
|
||||
${diffText}
|
||||
\`\`\`
|
||||
|
||||
Evaluate only the changes shown in the diff and provide your decision.`;
|
||||
|
||||
// Call OpenRouter API with streaming
|
||||
const openRouterResponse = await fetch('https://openrouter.ai/api/v1/chat/completions', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${apiKey}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model: 'openai/gpt-oss-120b',
|
||||
messages: [
|
||||
{ role: 'system', content: systemPrompt },
|
||||
{ role: 'user', content: userPrompt },
|
||||
],
|
||||
stream: true,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!openRouterResponse.ok) {
|
||||
const error = await openRouterResponse.text();
|
||||
console.error('OpenRouter error:', error);
|
||||
return new Response('Failed to review article', { status: 500 });
|
||||
}
|
||||
|
||||
// Transform the OpenRouter SSE stream
|
||||
const encoder = new TextEncoder();
|
||||
const decoder = new TextDecoder();
|
||||
|
||||
const transformStream = new TransformStream({
|
||||
async transform(chunk, controller) {
|
||||
const text = decoder.decode(chunk);
|
||||
const lines = text.split('\n');
|
||||
|
||||
for (const line of lines) {
|
||||
if (line.startsWith('data: ')) {
|
||||
const data = line.slice(6);
|
||||
if (data === '[DONE]') {
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
const parsed = JSON.parse(data);
|
||||
const contentChunk = parsed.choices?.[0]?.delta?.content;
|
||||
if (contentChunk) {
|
||||
controller.enqueue(encoder.encode(contentChunk));
|
||||
}
|
||||
} catch {
|
||||
// Skip invalid JSON
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
const stream = openRouterResponse.body?.pipeThrough(transformStream);
|
||||
|
||||
return new Response(stream, {
|
||||
headers: {
|
||||
'Content-Type': 'text/plain; charset=utf-8',
|
||||
'Transfer-Encoding': 'chunked',
|
||||
'Cache-Control': 'no-cache',
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { prisma } from '@/lib/db';
|
||||
|
||||
export async function POST(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ slug: string }> }
|
||||
) {
|
||||
const { slug } = await params;
|
||||
|
||||
const body = await request.json();
|
||||
const { newContent, reason } = body;
|
||||
|
||||
if (!newContent || typeof newContent !== 'string') {
|
||||
return NextResponse.json({ error: 'Missing newContent' }, { status: 400 });
|
||||
}
|
||||
|
||||
if (!reason || typeof reason !== 'string') {
|
||||
return NextResponse.json({ error: 'Missing reason' }, { status: 400 });
|
||||
}
|
||||
|
||||
// Fetch current article
|
||||
const article = await prisma.article.findUnique({
|
||||
where: { slug },
|
||||
});
|
||||
|
||||
if (!article) {
|
||||
return NextResponse.json({ error: 'Article not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
// Transaction: create history entry and update article
|
||||
const result = await prisma.$transaction(async (tx) => {
|
||||
// Create history entry
|
||||
const historyEntry = await tx.articleHistory.create({
|
||||
data: {
|
||||
articleId: article.id,
|
||||
oldContent: article.content,
|
||||
newContent: newContent,
|
||||
reason: reason,
|
||||
},
|
||||
});
|
||||
|
||||
// Update article content
|
||||
await tx.article.update({
|
||||
where: { id: article.id },
|
||||
data: {
|
||||
content: newContent,
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
});
|
||||
|
||||
return historyEntry;
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
historyId: result.id,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import type { WikipediaTopic } from '@/lib/types';
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
const response = await fetch(
|
||||
'https://en.wikipedia.org/api/rest_v1/page/random/summary',
|
||||
{
|
||||
headers: {
|
||||
'User-Agent': 'TeachMeSomethingNew/1.0',
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to fetch from Wikipedia API');
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
const topic: WikipediaTopic = {
|
||||
title: data.title,
|
||||
extract: data.extract,
|
||||
imageUrl: data.thumbnail?.source || data.originalimage?.source,
|
||||
pageUrl: data.content_urls?.desktop?.page,
|
||||
};
|
||||
|
||||
return NextResponse.json(topic);
|
||||
} catch (error) {
|
||||
console.error('Error fetching random topic:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to fetch random topic' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { prisma } from '@/lib/db';
|
||||
|
||||
export interface SearchResult {
|
||||
title: string;
|
||||
description: string;
|
||||
thumbnail?: string;
|
||||
}
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const searchParams = request.nextUrl.searchParams;
|
||||
const query = searchParams.get('query');
|
||||
|
||||
if (!query) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Query parameter is required' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
// Use the Wikipedia Action API for search
|
||||
const response = await fetch(
|
||||
`https://en.wikipedia.org/w/api.php?action=query&list=search&srsearch=${encodeURIComponent(query)}&srlimit=20&format=json&origin=*`,
|
||||
{
|
||||
headers: {
|
||||
'User-Agent': 'WikiPlus/1.0',
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to fetch from Wikipedia API');
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
// Get page IDs for thumbnail fetching
|
||||
const searchResults = data.query?.search || [];
|
||||
const pageIds = searchResults.map((r: { pageid: number }) => r.pageid).join('|');
|
||||
|
||||
// Fetch thumbnails for results
|
||||
let thumbnails: Record<string, string> = {};
|
||||
if (pageIds) {
|
||||
const thumbResponse = await fetch(
|
||||
`https://en.wikipedia.org/w/api.php?action=query&pageids=${pageIds}&prop=pageimages&pithumbsize=200&format=json&origin=*`,
|
||||
{
|
||||
headers: {
|
||||
'User-Agent': 'WikiPlus/1.0',
|
||||
},
|
||||
}
|
||||
);
|
||||
if (thumbResponse.ok) {
|
||||
const thumbData = await thumbResponse.json();
|
||||
const pages = thumbData.query?.pages || {};
|
||||
for (const pageId in pages) {
|
||||
if (pages[pageId].thumbnail) {
|
||||
thumbnails[pageId] = pages[pageId].thumbnail.source;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const results: SearchResult[] = searchResults.map((item: {
|
||||
pageid: number;
|
||||
title: string;
|
||||
snippet: string;
|
||||
}) => ({
|
||||
title: item.title,
|
||||
description: item.snippet.replace(/<[^>]*>/g, ''), // Strip HTML tags from snippet
|
||||
thumbnail: thumbnails[item.pageid.toString()],
|
||||
}));
|
||||
|
||||
// Log the search
|
||||
try {
|
||||
await prisma.search.create({
|
||||
data: {
|
||||
query,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error logging search:', error);
|
||||
// Don't fail the search if logging fails
|
||||
}
|
||||
|
||||
return NextResponse.json({ results });
|
||||
} catch (error) {
|
||||
console.error('Error searching Wikipedia:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to search Wikipedia' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import { prisma } from '@/lib/db';
|
||||
import { NextResponse } from 'next/server';
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
const today = new Date();
|
||||
today.setHours(0, 0, 0, 0);
|
||||
|
||||
const [totalArticles, searchesToday, allArticles] = await Promise.all([
|
||||
prisma.article.count(),
|
||||
prisma.search.count({
|
||||
where: {
|
||||
createdAt: {
|
||||
gte: today,
|
||||
},
|
||||
},
|
||||
}),
|
||||
prisma.article.findMany({
|
||||
select: {
|
||||
content: true,
|
||||
},
|
||||
}),
|
||||
]);
|
||||
|
||||
const totalWords = allArticles.reduce((sum, article) => {
|
||||
const words = article.content.split(/\s+/).filter(word => word.length > 0).length;
|
||||
return sum + words;
|
||||
}, 0);
|
||||
|
||||
return NextResponse.json({
|
||||
totalArticles,
|
||||
searchesToday,
|
||||
totalWords,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error fetching stats:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to fetch statistics' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
'use client';
|
||||
|
||||
import { use, useState } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { ArrowLeft, ExternalLink, Pencil, Clock } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { PageTransition } from '@/components/page-transition';
|
||||
import { ArticleStream } from '@/components/article-stream';
|
||||
import { HistoryModal } from '@/components/history-modal';
|
||||
|
||||
export default function ArticlePage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ title: string }>;
|
||||
}) {
|
||||
const { title } = use(params);
|
||||
const decodedTitle = decodeURIComponent(title).replace(/_/g, ' ');
|
||||
const wikipediaUrl = `https://en.wikipedia.org/wiki/${title}`;
|
||||
const slug = title.replace(/%20/g, '_').replace(/ /g, '_');
|
||||
|
||||
const [isEditMode, setIsEditMode] = useState(false);
|
||||
const [isHistoryOpen, setIsHistoryOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<main className="min-h-screen bg-background">
|
||||
<header className="sticky top-0 z-10 bg-background/95 backdrop-blur border-b border-border">
|
||||
<div className="max-w-5xl mx-auto px-4 lg:px-8 py-3 flex items-center justify-between">
|
||||
<div className="flex items-center gap-4">
|
||||
<Link
|
||||
href="/"
|
||||
className="text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
<ArrowLeft className="w-5 h-5" />
|
||||
</Link>
|
||||
<Link href="/" className="text-xl font-bold text-foreground">
|
||||
Wiki<span className="text-blue-500">+</span>
|
||||
</Link>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="gap-2"
|
||||
onClick={() => setIsHistoryOpen(true)}
|
||||
title="View History"
|
||||
>
|
||||
<Clock className="w-4 h-4" />
|
||||
<span className="hidden sm:inline">History</span>
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="gap-2"
|
||||
onClick={() => setIsEditMode(!isEditMode)}
|
||||
title={isEditMode ? 'Cancel Edit' : 'Edit Article'}
|
||||
>
|
||||
<Pencil className="w-4 h-4" />
|
||||
<span className="hidden sm:inline">{isEditMode ? 'Cancel' : 'Edit'}</span>
|
||||
</Button>
|
||||
<a
|
||||
href={wikipediaUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-muted-foreground hover:text-foreground transition-colors p-2"
|
||||
title="View on Wikipedia"
|
||||
>
|
||||
<ExternalLink className="w-4 h-4" />
|
||||
</a>
|
||||
<PageTransition href="/random">
|
||||
<Button variant="ghost" size="sm" className="gap-2">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
className="w-4 h-4"
|
||||
>
|
||||
<path d="M21 12a9 9 0 0 0-9-9 9.75 9.75 0 0 0-6.74 2.74L3 8" />
|
||||
<path d="M3 3v5h5" />
|
||||
<path d="M3 12a9 9 0 0 0 9 9 9.75 9.75 0 0 0 6.74-2.74L21 16" />
|
||||
<path d="M16 16h5v5" />
|
||||
</svg>
|
||||
Random
|
||||
</Button>
|
||||
</PageTransition>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<article className="max-w-5xl mx-auto px-4 lg:px-8 py-8">
|
||||
<h1 className="text-3xl sm:text-4xl font-bold text-foreground mb-8 leading-tight lg:pl-72">
|
||||
{decodedTitle}
|
||||
</h1>
|
||||
|
||||
<ArticleStream
|
||||
title={title}
|
||||
slug={slug}
|
||||
decodedTitle={decodedTitle}
|
||||
isEditMode={isEditMode}
|
||||
onEditModeChange={setIsEditMode}
|
||||
/>
|
||||
</article>
|
||||
|
||||
<footer className="border-t border-border mt-12">
|
||||
<div className="max-w-5xl mx-auto px-4 lg:px-8 py-6 flex items-center justify-between lg:pl-72">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
AI-generated from Wikipedia
|
||||
</p>
|
||||
<a
|
||||
href={wikipediaUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-sm text-blue-500 hover:underline inline-flex items-center gap-1"
|
||||
>
|
||||
View original
|
||||
<ExternalLink className="w-3 h-3" />
|
||||
</a>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
<HistoryModal
|
||||
slug={slug}
|
||||
isOpen={isHistoryOpen}
|
||||
onClose={() => setIsHistoryOpen(false)}
|
||||
/>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
+1443
-12
File diff suppressed because it is too large
Load Diff
+7
-3
@@ -1,6 +1,10 @@
|
||||
import type { Metadata } from "next";
|
||||
import { 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`}
|
||||
>
|
||||
|
||||
+57
-57
@@ -1,65 +1,65 @@
|
||||
import Image from "next/image";
|
||||
'use client';
|
||||
|
||||
import { useState, FormEvent } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { PageTransition } from '@/components/page-transition';
|
||||
import { Search, Shuffle } from 'lucide-react';
|
||||
|
||||
export default function Home() {
|
||||
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.
|
||||
</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>
|
||||
</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}
|
||||
<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="text-muted-foreground mb-10">
|
||||
Search the encyclopedia
|
||||
</p>
|
||||
|
||||
<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>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const baseUrl = request.nextUrl.origin;
|
||||
|
||||
try {
|
||||
const response = await fetch(
|
||||
'https://en.wikipedia.org/api/rest_v1/page/random/summary',
|
||||
{
|
||||
headers: {
|
||||
'User-Agent': 'WikiPlus/1.0',
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to fetch from Wikipedia API');
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
// Convert title to URL-friendly format (replace spaces with underscores)
|
||||
const urlTitle = data.title.replace(/ /g, '_');
|
||||
|
||||
return NextResponse.redirect(
|
||||
new URL(`/summary/${encodeURIComponent(urlTitle)}`, baseUrl)
|
||||
);
|
||||
} catch (error) {
|
||||
console.error('Error fetching random article:', error);
|
||||
return NextResponse.redirect(
|
||||
new URL('/?error=failed', baseUrl)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,217 @@
|
||||
'use client';
|
||||
|
||||
import { useSearchParams, useRouter } from 'next/navigation';
|
||||
import { useState, useEffect, useRef, FormEvent, Suspense } from 'react';
|
||||
import Link from 'next/link';
|
||||
import Image from 'next/image';
|
||||
import { Search, ArrowLeft, FileText } from 'lucide-react';
|
||||
|
||||
interface SearchResult {
|
||||
title: string;
|
||||
description: string;
|
||||
thumbnail?: string;
|
||||
}
|
||||
|
||||
function SearchContent() {
|
||||
const searchParams = useSearchParams();
|
||||
const router = useRouter();
|
||||
const initialQuery = searchParams.get('query') || '';
|
||||
|
||||
const [query, setQuery] = useState(initialQuery);
|
||||
const [results, setResults] = useState<SearchResult[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [searched, setSearched] = useState(false);
|
||||
const lastSearchedQuery = useRef<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (initialQuery && initialQuery !== lastSearchedQuery.current) {
|
||||
lastSearchedQuery.current = initialQuery;
|
||||
performSearch(initialQuery);
|
||||
}
|
||||
}, [initialQuery]);
|
||||
|
||||
const performSearch = async (searchQuery: string) => {
|
||||
if (!searchQuery.trim()) return;
|
||||
|
||||
setLoading(true);
|
||||
setSearched(true);
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/search?query=${encodeURIComponent(searchQuery)}`);
|
||||
const data = await response.json();
|
||||
setResults(data.results || []);
|
||||
} catch (error) {
|
||||
console.error('Search failed:', error);
|
||||
setResults([]);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSearch = (e: FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (query.trim()) {
|
||||
router.push(`/search?query=${encodeURIComponent(query.trim())}`);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<main className="min-h-screen bg-background">
|
||||
{/* Clean header */}
|
||||
<header className="sticky top-0 z-10 bg-background/95 backdrop-blur-md border-b border-border/50">
|
||||
<div className="max-w-3xl mx-auto px-6 py-4">
|
||||
<div className="flex items-center gap-4">
|
||||
<Link
|
||||
href="/"
|
||||
className="p-2 -ml-2 text-muted-foreground hover:text-foreground transition-colors rounded-lg hover:bg-muted/50"
|
||||
aria-label="Back to home"
|
||||
>
|
||||
<ArrowLeft className="w-5 h-5" />
|
||||
</Link>
|
||||
|
||||
<form onSubmit={handleSearch} className="flex-1">
|
||||
<div className="flex items-center w-full px-4 py-2.5 rounded-xl border border-border/60 bg-muted/30 hover:bg-muted/50 focus-within:bg-background focus-within:border-foreground/20 focus-within:ring-1 focus-within:ring-foreground/10 transition-all">
|
||||
<Search className="w-4 h-4 text-muted-foreground mr-3 flex-shrink-0" />
|
||||
<input
|
||||
type="text"
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
placeholder="Search Wikipedia..."
|
||||
className="flex-1 bg-transparent outline-none text-foreground placeholder:text-muted-foreground/70 text-[15px]"
|
||||
autoFocus
|
||||
/>
|
||||
{query && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setQuery('')}
|
||||
className="ml-2 text-muted-foreground hover:text-foreground transition-colors text-sm"
|
||||
>
|
||||
Clear
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Results area */}
|
||||
<div className="max-w-3xl mx-auto px-6 py-8">
|
||||
{loading ? (
|
||||
<div className="space-y-6">
|
||||
{[...Array(5)].map((_, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="flex gap-4 animate-pulse"
|
||||
style={{ animationDelay: `${i * 100}ms` }}
|
||||
>
|
||||
<div className="w-20 h-20 rounded-lg bg-muted/60 shrink-0" />
|
||||
<div className="flex-1 py-1">
|
||||
<div className="h-5 bg-muted/60 rounded-md w-2/5 mb-3" />
|
||||
<div className="h-4 bg-muted/40 rounded-md w-4/5 mb-2" />
|
||||
<div className="h-4 bg-muted/40 rounded-md w-3/5" />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : results.length > 0 ? (
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground mb-6">
|
||||
{results.length} result{results.length !== 1 ? 's' : ''} for <span className="text-foreground font-medium">"{initialQuery}"</span>
|
||||
</p>
|
||||
<div className="space-y-2">
|
||||
{results.map((result, index) => (
|
||||
<Link
|
||||
key={result.title}
|
||||
href={`/article/${encodeURIComponent(result.title.replace(/ /g, '_'))}`}
|
||||
className="group block p-4 -mx-4 rounded-xl hover:bg-muted/40 active:bg-muted/60 transition-all duration-200"
|
||||
style={{
|
||||
animation: 'searchResultFadeIn 0.3s ease-out forwards',
|
||||
animationDelay: `${index * 50}ms`,
|
||||
opacity: 0
|
||||
}}
|
||||
>
|
||||
<div className="flex gap-4">
|
||||
{result.thumbnail ? (
|
||||
<div className="relative w-20 h-20 rounded-lg overflow-hidden bg-muted shrink-0 ring-1 ring-border/50">
|
||||
<Image
|
||||
src={result.thumbnail.startsWith('//') ? `https:${result.thumbnail}` : result.thumbnail}
|
||||
alt=""
|
||||
fill
|
||||
className="object-cover group-hover:scale-105 transition-transform duration-300"
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="w-20 h-20 rounded-lg bg-muted/50 shrink-0 flex items-center justify-center ring-1 ring-border/30">
|
||||
<FileText className="w-6 h-6 text-muted-foreground/50" />
|
||||
</div>
|
||||
)}
|
||||
<div className="flex-1 min-w-0 py-0.5">
|
||||
<h2 className="text-[17px] font-medium text-foreground group-hover:text-blue-600 dark:group-hover:text-blue-400 transition-colors leading-snug">
|
||||
{result.title}
|
||||
</h2>
|
||||
{result.description && (
|
||||
<p className="text-[14px] text-muted-foreground line-clamp-2 mt-1.5 leading-relaxed">
|
||||
{result.description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : searched ? (
|
||||
<div className="text-center py-16">
|
||||
<div className="w-16 h-16 mx-auto mb-4 rounded-full bg-muted/50 flex items-center justify-center">
|
||||
<Search className="w-7 h-7 text-muted-foreground/50" />
|
||||
</div>
|
||||
<p className="text-muted-foreground">
|
||||
No results found for <span className="text-foreground font-medium">"{initialQuery}"</span>
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground/70 mt-2">
|
||||
Try different keywords or check the spelling
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center py-16">
|
||||
<div className="w-16 h-16 mx-auto mb-4 rounded-full bg-muted/50 flex items-center justify-center">
|
||||
<Search className="w-7 h-7 text-muted-foreground/50" />
|
||||
</div>
|
||||
<p className="text-muted-foreground">
|
||||
Enter a search term to find articles
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<style jsx>{`
|
||||
@keyframes searchResultFadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(8px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
`}</style>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
export default function SearchPage() {
|
||||
return (
|
||||
<Suspense fallback={
|
||||
<main className="min-h-screen bg-background flex items-center justify-center">
|
||||
<div className="text-muted-foreground">
|
||||
<div className="w-8 h-8 border-2 border-muted-foreground/30 border-t-muted-foreground rounded-full animate-spin mx-auto mb-3" />
|
||||
<span className="text-sm">Loading...</span>
|
||||
</div>
|
||||
</main>
|
||||
}>
|
||||
<SearchContent />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
import { faArrowsRotate, faArrowUpRightFromSquare } from '@fortawesome/free-solid-svg-icons';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import Image from 'next/image';
|
||||
import Link from 'next/link';
|
||||
import { notFound } from 'next/navigation';
|
||||
import { PageTransition } from '@/components/page-transition';
|
||||
|
||||
interface WikipediaSummary {
|
||||
title: string;
|
||||
extract: string;
|
||||
thumbnail?: {
|
||||
source: string;
|
||||
};
|
||||
originalimage?: {
|
||||
source: string;
|
||||
};
|
||||
content_urls?: {
|
||||
desktop?: {
|
||||
page: string;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
async function getArticleSummary(title: string): Promise<WikipediaSummary | null> {
|
||||
try {
|
||||
// Wikipedia API expects underscores instead of %20 for spaces
|
||||
const apiTitle = title.replace(/%20/g, '_');
|
||||
|
||||
const response = await fetch(
|
||||
`https://en.wikipedia.org/api/rest_v1/page/summary/${apiTitle}`,
|
||||
{
|
||||
headers: {
|
||||
'User-Agent': 'WikiPlus/1.0',
|
||||
},
|
||||
cache: 'no-store',
|
||||
}
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return await response.json();
|
||||
} catch (error) {
|
||||
console.error('Error fetching article summary:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export default async function SummaryPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ title: string }>;
|
||||
}) {
|
||||
const { title } = await params;
|
||||
const article = await getArticleSummary(title);
|
||||
|
||||
if (!article) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
const imageUrl = article.thumbnail?.source || article.originalimage?.source;
|
||||
const pageUrl = article.content_urls?.desktop?.page;
|
||||
|
||||
return (
|
||||
<main className="min-h-screen py-10 sm:py-16 px-4 sm:px-6 overflow-hidden">
|
||||
<div className="max-w-2xl mx-auto">
|
||||
<header className="mb-8 sm:mb-12 animate-[slideInFromTop_0.6s_ease-out]">
|
||||
<Link href="/" className="text-gray-500 hover:text-gray-300 text-sm uppercase tracking-widest mb-4 inline-block transition-colors">
|
||||
Wiki<span className="text-blue-500">+</span>
|
||||
</Link>
|
||||
<h1 className="text-3xl sm:text-4xl md:text-5xl font-light text-gray-100 tracking-tight">
|
||||
{article.title}
|
||||
</h1>
|
||||
</header>
|
||||
|
||||
{imageUrl && (
|
||||
<div className="relative w-full aspect-video mb-8 sm:mb-10 rounded-lg overflow-hidden bg-gray-800/50 animate-[fadeInScale_0.8s_ease-out_0.2s_both]">
|
||||
<Image
|
||||
src={imageUrl}
|
||||
alt={article.title}
|
||||
fill
|
||||
className="object-contain"
|
||||
priority
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<article className="mb-8 sm:mb-12 animate-[slideInFromBottom_0.6s_ease-out_0.4s_both]">
|
||||
<p className="text-gray-300 text-lg sm:text-xl leading-relaxed font-light">
|
||||
{article.extract}
|
||||
</p>
|
||||
</article>
|
||||
|
||||
<footer className="flex flex-col-reverse sm:flex-row items-center justify-between gap-4 pt-6 sm:pt-8 border-t border-gray-800 animate-[slideInFromBottom_0.6s_ease-out_0.6s_both]">
|
||||
<Link
|
||||
href={`/article/${encodeURIComponent(article.title)}`}
|
||||
className="text-gray-500 hover:text-gray-300 text-sm inline-flex items-center gap-2 transition-colors"
|
||||
>
|
||||
Full article
|
||||
<FontAwesomeIcon icon={faArrowUpRightFromSquare} className="text-xs" />
|
||||
</Link>
|
||||
<PageTransition href="/random">
|
||||
<Button variant="outline" className="gap-2 hover:scale-105 active:scale-95 transition-transform w-full sm:w-auto">
|
||||
<FontAwesomeIcon icon={faArrowsRotate} />
|
||||
New Article
|
||||
</Button>
|
||||
</PageTransition>
|
||||
</footer>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user