Major changes

This commit is contained in:
owenqwenstarsky
2026-01-24 16:52:19 -06:00
parent 011e40119d
commit 7181791734
46 changed files with 8371 additions and 127 deletions
+252
View File
@@ -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(/&nbsp;/g, ' ')
.replace(/&amp;/g, '&')
.replace(/&lt;/g, '<')
.replace(/&gt;/g, '>')
.replace(/&quot;/g, '"')
.replace(/&#39;/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: ![Description](url)
- 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',
},
});
}
+34
View File
@@ -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);
}
+147
View File
@@ -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',
},
});
}
+58
View File
@@ -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,
});
}
+36
View File
@@ -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 }
);
}
}
+94
View File
@@ -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 }
);
}
}
+42
View File
@@ -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 }
);
}
}
+131
View File
@@ -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
View File
File diff suppressed because it is too large Load Diff
+7 -3
View File
@@ -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
View File
@@ -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>
);
}
+34
View File
@@ -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)
);
}
}
+217
View File
@@ -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>
);
}
+114
View File
@@ -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>
);
}