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
+2
View File
@@ -39,3 +39,5 @@ yarn-error.log*
# typescript # typescript
*.tsbuildinfo *.tsbuildinfo
next-env.d.ts next-env.d.ts
/lib/generated/prisma
-36
View File
@@ -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.
+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>
);
}
+1442 -11
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 type { Metadata } from "next";
import { Geist, Geist_Mono } from "next/font/google"; import { Geist, Geist_Mono } from "next/font/google";
import "./globals.css"; import "./globals.css";
import { config } from '@fortawesome/fontawesome-svg-core';
import '@fortawesome/fontawesome-svg-core/styles.css';
config.autoAddCss = false;
const geistSans = Geist({ const geistSans = Geist({
variable: "--font-geist-sans", variable: "--font-geist-sans",
@@ -13,8 +17,8 @@ const geistMono = Geist_Mono({
}); });
export const metadata: Metadata = { export const metadata: Metadata = {
title: "Create Next App", title: "Wiki+",
description: "Generated by create next app", description: "Discover random knowledge from Wikipedia",
}; };
export default function RootLayout({ export default function RootLayout({
@@ -23,7 +27,7 @@ export default function RootLayout({
children: React.ReactNode; children: React.ReactNode;
}>) { }>) {
return ( return (
<html lang="en"> <html lang="en" className="dark">
<body <body
className={`${geistSans.variable} ${geistMono.variable} antialiased`} className={`${geistSans.variable} ${geistMono.variable} antialiased`}
> >
+54 -54
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() { export default function Home() {
const [query, setQuery] = useState('');
const router = useRouter();
const handleSearch = (e: FormEvent) => {
e.preventDefault();
if (query.trim()) {
router.push(`/search?query=${encodeURIComponent(query.trim())}`);
}
};
return ( return (
<div className="flex min-h-screen items-center justify-center bg-zinc-50 font-sans dark:bg-black"> <main className="min-h-screen flex flex-col items-center justify-center px-6">
<main className="flex min-h-screen w-full max-w-3xl flex-col items-center justify-between py-32 px-16 bg-white dark:bg-black sm:items-start"> <div className="w-full max-w-xl text-center">
<Image <h1 className="text-5xl sm:text-6xl font-semibold tracking-tight text-foreground mb-3">
className="dark:invert" Wiki<span className="text-neutral-400">+</span>
src="/next.svg"
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> </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{" "} <p className="text-muted-foreground mb-10">
<a Search the encyclopedia
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> </p>
</div>
<div className="flex flex-col gap-4 text-base font-medium sm:flex-row"> <form onSubmit={handleSearch} className="w-full mb-6">
<a <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">
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]" <Search className="w-5 h-5 text-muted-foreground mr-3 flex-shrink-0" />
href="https://vercel.com/new?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app" <input
target="_blank" type="text"
rel="noopener noreferrer" value={query}
> onChange={(e) => setQuery(e.target.value)}
<Image placeholder="Search for anything..."
className="dark:invert" className="flex-1 bg-transparent outline-none text-foreground placeholder:text-muted-foreground"
src="/vercel.svg" autoFocus
alt="Vercel logomark"
width={16}
height={16}
/> />
Deploy Now </div>
</a> </form>
<a
className="flex h-12 w-full items-center justify-center rounded-full border border-solid border-black/[.08] px-5 transition-colors hover:border-transparent hover:bg-black/[.04] dark:border-white/[.145] dark:hover:bg-[#1a1a1a] md:w-[158px]" <div className="flex gap-3 justify-center">
href="https://nextjs.org/docs?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app" <button
target="_blank" type="button"
rel="noopener noreferrer" onClick={handleSearch}
className="px-5 py-2.5 text-sm font-medium bg-foreground text-background rounded-lg hover:opacity-90 transition-opacity"
> >
Documentation Search
</a> </button>
<PageTransition href="/random">
<button
type="button"
className="px-5 py-2.5 text-sm font-medium text-muted-foreground border border-border rounded-lg hover:bg-accent hover:text-foreground transition-colors inline-flex items-center gap-2"
>
<Shuffle className="w-4 h-4" />
Random
</button>
</PageTransition>
</div>
</div> </div>
</main> </main>
</div>
); );
} }
+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>
);
}
+22
View File
@@ -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": {}
}
+791
View File
@@ -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) + `![Image](${selectedText})` + content.substring(end);
updateContent(newText);
} else {
insertAtCursor('![alt text](image-url)');
}
}, [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>
);
}
+266
View File
@@ -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>
);
}
+42
View File
@@ -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>
);
}
+103
View File
@@ -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>
);
}
+95
View File
@@ -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>
);
}
+27
View File
@@ -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>
);
}
+375
View File
@@ -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>
);
}
+101
View File
@@ -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>
);
}
+25
View File
@@ -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>
);
}
+119
View File
@@ -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>
);
}
+67
View File
@@ -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>
);
}
+113
View File
@@ -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>
);
}
+64
View File
@@ -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 }
+92
View File
@@ -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,
}
+61
View File
@@ -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 }
+34
View File
@@ -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 }}
/>
);
}
+19
View File
@@ -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;
}
+6
View File
@@ -0,0 +1,6 @@
export interface WikipediaTopic {
title: string;
extract: string;
imageUrl?: string;
pageUrl: string;
}
+6
View File
@@ -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
View File
@@ -1,7 +1,18 @@
import type { NextConfig } from "next"; import type { NextConfig } from "next";
const nextConfig: NextConfig = { const nextConfig: NextConfig = {
/* config options here */ images: {
remotePatterns: [
{
protocol: 'https',
hostname: 'upload.wikimedia.org',
},
{
protocol: 'https',
hostname: '*.wikipedia.org',
},
],
},
}; };
export default nextConfig; export default nextConfig;
+3126 -16
View File
File diff suppressed because it is too large Load Diff
+21 -1
View File
@@ -9,18 +9,38 @@
"lint": "eslint" "lint": "eslint"
}, },
"dependencies": { "dependencies": {
"@fortawesome/fontawesome-svg-core": "^7.1.0",
"@fortawesome/free-solid-svg-icons": "^7.1.0",
"@fortawesome/react-fontawesome": "^3.1.1",
"@prisma/adapter-pg": "^7.3.0",
"@prisma/client": "^7.3.0",
"@radix-ui/react-slot": "^1.2.4",
"@radix-ui/react-tooltip": "^1.2.8",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"diff": "^8.0.3",
"dotenv": "^17.2.3",
"lucide-react": "^0.563.0",
"next": "16.1.4", "next": "16.1.4",
"pg": "^8.17.2",
"prisma": "^7.3.0",
"react": "19.2.3", "react": "19.2.3",
"react-dom": "19.2.3" "react-dom": "19.2.3",
"react-markdown": "^10.1.0",
"remark-gfm": "^4.0.1",
"tailwind-merge": "^3.4.0"
}, },
"devDependencies": { "devDependencies": {
"@tailwindcss/postcss": "^4", "@tailwindcss/postcss": "^4",
"@types/diff": "^7.0.2",
"@types/node": "^20", "@types/node": "^20",
"@types/pg": "^8.16.0",
"@types/react": "^19", "@types/react": "^19",
"@types/react-dom": "^19", "@types/react-dom": "^19",
"eslint": "^9", "eslint": "^9",
"eslint-config-next": "16.1.4", "eslint-config-next": "16.1.4",
"tailwindcss": "^4", "tailwindcss": "^4",
"tw-animate-css": "^1.4.0",
"typescript": "^5" "typescript": "^5"
} }
} }
+19
View File
@@ -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");
+3
View File
@@ -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"
+44
View File
@@ -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
View File
@@ -30,5 +30,5 @@
".next/dev/types/**/*.ts", ".next/dev/types/**/*.ts",
"**/*.mts" "**/*.mts"
], ],
"exclude": ["node_modules"] "exclude": ["node_modules", "prisma.config.ts"]
} }