From 71817917347da3becb9ab3e15c5384bb424cfeea Mon Sep 17 00:00:00 2001 From: owenqwenstarsky Date: Sat, 24 Jan 2026 16:52:19 -0600 Subject: [PATCH] Major changes --- .gitignore | 2 + README.md | 36 - app/api/ai-article/route.ts | 252 ++ app/api/article/[slug]/history/route.ts | 34 + app/api/article/[slug]/review/route.ts | 147 + app/api/article/[slug]/save/route.ts | 58 + app/api/random-topic/route.ts | 36 + app/api/search/route.ts | 94 + app/api/stats/route.ts | 42 + app/article/[title]/page.tsx | 131 + app/globals.css | 1455 +++++++- app/layout.tsx | 10 +- app/page.tsx | 114 +- app/random/route.ts | 34 + app/search/page.tsx | 217 ++ app/summary/[title]/page.tsx | 114 + components.json | 22 + components/article-editor.tsx | 791 +++++ components/article-stream.tsx | 266 ++ components/cursor-glow.tsx | 42 + components/diff-preview.tsx | 103 + components/diff-viewer.tsx | 95 + components/editor-preview.tsx | 27 + components/editor-toolbar.tsx | 375 ++ components/history-modal.tsx | 101 + components/page-transition.tsx | 25 + components/related-articles.tsx | 119 + components/review-result-panel.tsx | 67 + components/table-of-contents.tsx | 113 + components/ui/button.tsx | 64 + components/ui/card.tsx | 92 + components/ui/tooltip.tsx | 61 + components/wiki-content.tsx | 34 + lib/db.ts | 19 + lib/types.ts | 6 + lib/utils.ts | 6 + next.config.ts | 13 +- package-lock.json | 3142 ++++++++++++++++- package.json | 22 +- prisma.config.ts | 19 + .../20260124190621_init/migration.sql | 18 + .../migration.sql | 20 + .../20260124213109_init/migration.sql | 11 + prisma/migrations/migration_lock.toml | 3 + prisma/schema.prisma | 44 + tsconfig.json | 2 +- 46 files changed, 8371 insertions(+), 127 deletions(-) delete mode 100644 README.md create mode 100644 app/api/ai-article/route.ts create mode 100644 app/api/article/[slug]/history/route.ts create mode 100644 app/api/article/[slug]/review/route.ts create mode 100644 app/api/article/[slug]/save/route.ts create mode 100644 app/api/random-topic/route.ts create mode 100644 app/api/search/route.ts create mode 100644 app/api/stats/route.ts create mode 100644 app/article/[title]/page.tsx create mode 100644 app/random/route.ts create mode 100644 app/search/page.tsx create mode 100644 app/summary/[title]/page.tsx create mode 100644 components.json create mode 100644 components/article-editor.tsx create mode 100644 components/article-stream.tsx create mode 100644 components/cursor-glow.tsx create mode 100644 components/diff-preview.tsx create mode 100644 components/diff-viewer.tsx create mode 100644 components/editor-preview.tsx create mode 100644 components/editor-toolbar.tsx create mode 100644 components/history-modal.tsx create mode 100644 components/page-transition.tsx create mode 100644 components/related-articles.tsx create mode 100644 components/review-result-panel.tsx create mode 100644 components/table-of-contents.tsx create mode 100644 components/ui/button.tsx create mode 100644 components/ui/card.tsx create mode 100644 components/ui/tooltip.tsx create mode 100644 components/wiki-content.tsx create mode 100644 lib/db.ts create mode 100644 lib/types.ts create mode 100644 lib/utils.ts create mode 100644 prisma.config.ts create mode 100644 prisma/migrations/20260124190621_init/migration.sql create mode 100644 prisma/migrations/20260124195752_add_article_history/migration.sql create mode 100644 prisma/migrations/20260124213109_init/migration.sql create mode 100644 prisma/migrations/migration_lock.toml create mode 100644 prisma/schema.prisma diff --git a/.gitignore b/.gitignore index 5ef6a52..62fcee8 100644 --- a/.gitignore +++ b/.gitignore @@ -39,3 +39,5 @@ yarn-error.log* # typescript *.tsbuildinfo next-env.d.ts + +/lib/generated/prisma diff --git a/README.md b/README.md deleted file mode 100644 index e215bc4..0000000 --- a/README.md +++ /dev/null @@ -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. diff --git a/app/api/ai-article/route.ts b/app/api/ai-article/route.ts new file mode 100644 index 0000000..54906a8 --- /dev/null +++ b/app/api/ai-article/route.ts @@ -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 { + 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(/]*>[\s\S]*?<\/style>/gi, '') + .replace(/]*>[\s\S]*?<\/script>/gi, '') + .replace(/<[^>]+>/g, ' ') + .replace(/ /g, ' ') + .replace(/&/g, '&') + .replace(/</g, '<') + .replace(/>/g, '>') + .replace(/"/g, '"') + .replace(/'/g, "'") + .replace(/\s+/g, ' ') + .trim(); + + // Get image URLs + const images = (data.parse.images || []) + .filter((img: string) => !img.includes('icon') && !img.includes('logo') && !img.includes('Commons-logo')) + .slice(0, 5) + .map((img: string) => `https://en.wikipedia.org/wiki/Special:FilePath/${encodeURIComponent(img)}`); + + return { text: plainText.slice(0, 15000), images }; + } catch { + return null; + } +} + +export async function GET(request: NextRequest) { + const searchParams = request.nextUrl.searchParams; + const title = searchParams.get('title'); + + if (!title) { + return new Response('Missing title parameter', { status: 400 }); + } + + // Normalize the slug for database lookup + const slug = title.replace(/%20/g, '_').replace(/ /g, '_'); + + // Check if article exists in database + const existingArticle = await prisma.article.findUnique({ + where: { slug }, + }); + + if (existingArticle) { + // Return cached article wrapped in
tags + const cachedContent = `
${existingArticle.content}
`; + 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
tags +- Do not include any text outside the
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
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
tags and save to database + const articleMatch = fullContent.match(/
([\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', + }, + }); +} diff --git a/app/api/article/[slug]/history/route.ts b/app/api/article/[slug]/history/route.ts new file mode 100644 index 0000000..4c60fb9 --- /dev/null +++ b/app/api/article/[slug]/history/route.ts @@ -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); +} diff --git a/app/api/article/[slug]/review/route.ts b/app/api/article/[slug]/review/route.ts new file mode 100644 index 0000000..7c6b3ea --- /dev/null +++ b/app/api/article/[slug]/review/route.ts @@ -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: + +OR + + +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', + }, + }); +} diff --git a/app/api/article/[slug]/save/route.ts b/app/api/article/[slug]/save/route.ts new file mode 100644 index 0000000..1f7e1d5 --- /dev/null +++ b/app/api/article/[slug]/save/route.ts @@ -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, + }); +} diff --git a/app/api/random-topic/route.ts b/app/api/random-topic/route.ts new file mode 100644 index 0000000..8f310ba --- /dev/null +++ b/app/api/random-topic/route.ts @@ -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 } + ); + } +} diff --git a/app/api/search/route.ts b/app/api/search/route.ts new file mode 100644 index 0000000..843f524 --- /dev/null +++ b/app/api/search/route.ts @@ -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 = {}; + 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 } + ); + } +} diff --git a/app/api/stats/route.ts b/app/api/stats/route.ts new file mode 100644 index 0000000..099db12 --- /dev/null +++ b/app/api/stats/route.ts @@ -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 } + ); + } +} diff --git a/app/article/[title]/page.tsx b/app/article/[title]/page.tsx new file mode 100644 index 0000000..62f7b2f --- /dev/null +++ b/app/article/[title]/page.tsx @@ -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 ( +
+
+
+
+ + + + + Wiki+ + +
+
+ + + + + + + + +
+
+
+ +
+

+ {decodedTitle} +

+ + +
+ + + + setIsHistoryOpen(false)} + /> +
+ ); +} diff --git a/app/globals.css b/app/globals.css index a2dc41e..cfe5688 100644 --- a/app/globals.css +++ b/app/globals.css @@ -1,26 +1,1457 @@ @import "tailwindcss"; +@import "tw-animate-css"; -:root { - --background: #ffffff; - --foreground: #171717; -} +@custom-variant dark (&:is(.dark *)); @theme inline { --color-background: var(--background); --color-foreground: var(--foreground); --font-sans: var(--font-geist-sans); --font-mono: var(--font-geist-mono); + --color-sidebar-ring: var(--sidebar-ring); + --color-sidebar-border: var(--sidebar-border); + --color-sidebar-accent-foreground: var(--sidebar-accent-foreground); + --color-sidebar-accent: var(--sidebar-accent); + --color-sidebar-primary-foreground: var(--sidebar-primary-foreground); + --color-sidebar-primary: var(--sidebar-primary); + --color-sidebar-foreground: var(--sidebar-foreground); + --color-sidebar: var(--sidebar); + --color-chart-5: var(--chart-5); + --color-chart-4: var(--chart-4); + --color-chart-3: var(--chart-3); + --color-chart-2: var(--chart-2); + --color-chart-1: var(--chart-1); + --color-ring: var(--ring); + --color-input: var(--input); + --color-border: var(--border); + --color-destructive: var(--destructive); + --color-accent-foreground: var(--accent-foreground); + --color-accent: var(--accent); + --color-muted-foreground: var(--muted-foreground); + --color-muted: var(--muted); + --color-secondary-foreground: var(--secondary-foreground); + --color-secondary: var(--secondary); + --color-primary-foreground: var(--primary-foreground); + --color-primary: var(--primary); + --color-popover-foreground: var(--popover-foreground); + --color-popover: var(--popover); + --color-card-foreground: var(--card-foreground); + --color-card: var(--card); + --radius-sm: calc(var(--radius) - 4px); + --radius-md: calc(var(--radius) - 2px); + --radius-lg: var(--radius); + --radius-xl: calc(var(--radius) + 4px); + --radius-2xl: calc(var(--radius) + 8px); + --radius-3xl: calc(var(--radius) + 12px); + --radius-4xl: calc(var(--radius) + 16px); } -@media (prefers-color-scheme: dark) { - :root { - --background: #0a0a0a; - --foreground: #ededed; +:root { + --radius: 0.625rem; + --background: oklch(1 0 0); + --foreground: oklch(0.145 0 0); + --card: oklch(1 0 0); + --card-foreground: oklch(0.145 0 0); + --popover: oklch(1 0 0); + --popover-foreground: oklch(0.145 0 0); + --primary: oklch(0.205 0 0); + --primary-foreground: oklch(0.985 0 0); + --secondary: oklch(0.97 0 0); + --secondary-foreground: oklch(0.205 0 0); + --muted: oklch(0.97 0 0); + --muted-foreground: oklch(0.556 0 0); + --accent: oklch(0.97 0 0); + --accent-foreground: oklch(0.205 0 0); + --destructive: oklch(0.577 0.245 27.325); + --border: oklch(0.922 0 0); + --input: oklch(0.922 0 0); + --ring: oklch(0.708 0 0); + --chart-1: oklch(0.646 0.222 41.116); + --chart-2: oklch(0.6 0.118 184.704); + --chart-3: oklch(0.398 0.07 227.392); + --chart-4: oklch(0.828 0.189 84.429); + --chart-5: oklch(0.769 0.188 70.08); + --sidebar: oklch(0.985 0 0); + --sidebar-foreground: oklch(0.145 0 0); + --sidebar-primary: oklch(0.205 0 0); + --sidebar-primary-foreground: oklch(0.985 0 0); + --sidebar-accent: oklch(0.97 0 0); + --sidebar-accent-foreground: oklch(0.205 0 0); + --sidebar-border: oklch(0.922 0 0); + --sidebar-ring: oklch(0.708 0 0); +} + +.dark { + --background: oklch(0.13 0 0); + --foreground: oklch(0.93 0 0); + --card: oklch(0.17 0 0); + --card-foreground: oklch(0.93 0 0); + --popover: oklch(0.17 0 0); + --popover-foreground: oklch(0.93 0 0); + --primary: oklch(0.85 0 0); + --primary-foreground: oklch(0.13 0 0); + --secondary: oklch(0.22 0 0); + --secondary-foreground: oklch(0.93 0 0); + --muted: oklch(0.22 0 0); + --muted-foreground: oklch(0.6 0 0); + --accent: oklch(0.25 0 0); + --accent-foreground: oklch(0.93 0 0); + --destructive: oklch(0.5 0 0); + --border: oklch(0.25 0 0); + --input: oklch(0.22 0 0); + --ring: oklch(0.5 0 0); + --chart-1: oklch(0.7 0 0); + --chart-2: oklch(0.6 0 0); + --chart-3: oklch(0.5 0 0); + --chart-4: oklch(0.4 0 0); + --chart-5: oklch(0.3 0 0); + --sidebar: oklch(0.17 0 0); + --sidebar-foreground: oklch(0.93 0 0); + --sidebar-primary: oklch(0.7 0 0); + --sidebar-primary-foreground: oklch(0.93 0 0); + --sidebar-accent: oklch(0.22 0 0); + --sidebar-accent-foreground: oklch(0.93 0 0); + --sidebar-border: oklch(0.25 0 0); + --sidebar-ring: oklch(0.5 0 0); +} + +@layer base { + * { + @apply border-border outline-ring/50; + } + body { + @apply bg-background text-foreground; + -webkit-tap-highlight-color: transparent; + } + + /* Improve touch targets on mobile */ + button, a { + touch-action: manipulation; + } + + /* Prevent text size adjustment on orientation change */ + html { + -webkit-text-size-adjust: 100%; + text-size-adjust: 100%; } } -body { - background: var(--background); - color: var(--foreground); - font-family: Arial, Helvetica, sans-serif; + +/* Page entrance animations */ +@keyframes slideInFromTop { + 0% { + opacity: 0; + transform: translateY(-30px); + } + 100% { + opacity: 1; + transform: translateY(0); + } +} + +@keyframes slideInFromBottom { + 0% { + opacity: 0; + transform: translateY(30px); + } + 100% { + opacity: 1; + transform: translateY(0); + } +} + +@keyframes fadeInScale { + 0% { + opacity: 0; + transform: scale(0.95); + } + 100% { + opacity: 1; + transform: scale(1); + } +} + +/* Article content styling */ +.article-content { + font-size: 1.125rem; + line-height: 1.9; + color: var(--foreground); + letter-spacing: -0.01em; +} + +.article-content > * { + margin-bottom: 1.5rem; +} + +.article-content > *:last-child { + margin-bottom: 0; +} + +/* Typography */ +.article-content h1 { + font-size: 2rem; + font-weight: 700; + color: var(--foreground); + margin-top: 3rem; + margin-bottom: 1.5rem; + line-height: 1.3; +} + +.article-content h2 { + font-size: 1.625rem; + font-weight: 700; + color: var(--foreground); + margin-top: 3rem; + margin-bottom: 1.25rem; + padding-bottom: 0.625rem; + border-bottom: 2px solid var(--border); + line-height: 1.3; +} + +.article-content h3 { + font-size: 1.375rem; + font-weight: 600; + color: var(--foreground); + margin-top: 2.5rem; + margin-bottom: 1rem; + line-height: 1.4; +} + +.article-content h4, +.article-content h5, +.article-content h6 { + font-size: 1.125rem; + font-weight: 600; + color: var(--foreground); + margin-top: 2rem; + margin-bottom: 0.75rem; + line-height: 1.4; +} + +.article-content p { + margin-bottom: 1.5rem; + text-align: left; +} + +.article-content p + p { + margin-top: 0; +} + +.article-content a { + color: oklch(0.55 0.2 250); + text-decoration: underline; + text-underline-offset: 2px; + transition: color 0.15s; +} + +.dark .article-content a { + color: oklch(0.7 0.2 250); +} + +.article-content a:hover { + color: oklch(0.65 0.2 250); +} + +.dark .article-content a:hover { + color: oklch(0.8 0.2 250); +} + +.article-content strong, +.article-content b { + color: var(--foreground); + font-weight: 700; +} + +.article-content em, +.article-content i { + font-style: italic; +} + +/* Lists */ +.article-content ul, +.article-content ol { + margin: 1.5rem 0 2rem 0; + padding-left: 2rem; +} + +.article-content ul { + list-style-type: disc; +} + +.article-content ol { + list-style-type: decimal; +} + +.article-content li { + margin: 0.75rem 0; + padding-left: 0.5rem; + line-height: 1.8; +} + +.article-content li > ul, +.article-content li > ol { + margin: 0.75rem 0 0.75rem 0; +} + +.article-content li > p { + margin-bottom: 0.75rem; +} + +/* Blockquotes */ +.article-content blockquote { + border-left: 4px solid oklch(0.55 0.2 250); + background: var(--muted); + padding: 1.25rem 1.5rem; + margin: 2rem 0; + border-radius: 0 0.75rem 0.75rem 0; + font-style: italic; + color: var(--foreground); +} + +.article-content blockquote p { + margin-bottom: 0.75rem; +} + +.article-content blockquote p:last-child { + margin-bottom: 0; +} + +/* Horizontal rule */ +.article-content hr { + border: none; + border-top: 2px solid var(--border); + margin: 3rem 0; +} + +/* Tables */ +.article-content table { + width: 100%; + border-collapse: collapse; + margin: 2rem 0; + font-size: 1rem; +} + +.article-content th, +.article-content td { + padding: 1rem 1.25rem; + border: 1px solid var(--border); + text-align: left; + line-height: 1.6; +} + +.article-content th { + background: var(--muted); + font-weight: 700; + color: var(--foreground); +} + +.article-content tr:nth-child(even) td { + background: var(--muted); + opacity: 0.5; +} + +.article-content tr:hover td { + background: var(--accent); +} + +/* Code */ +.article-content code { + background: var(--muted); + padding: 0.2rem 0.5rem; + border-radius: 0.375rem; + font-size: 0.9em; + font-family: var(--font-mono); + color: var(--foreground); +} + +.article-content pre { + background: var(--muted); + padding: 1.25rem 1.5rem; + border-radius: 0.75rem; + overflow-x: auto; + margin: 2rem 0; + line-height: 1.6; +} + +.article-content pre code { + background: none; + padding: 0; + font-size: 0.875rem; +} + +/* Images */ +.article-content img { + max-width: 100%; + height: auto; + border-radius: 0.75rem; + margin: 2rem auto; + display: block; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1); + background-color: white; +} + +.dark .article-content img { + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3); +} + +/* Hide Wikipedia-specific elements */ +.article-content .mw-editsection, +.article-content .reference, +.article-content .noprint, +.article-content .navbox, +.article-content .vertical-navbox, +.article-content .sidebar, +.article-content .infobox, +.article-content .metadata, +.article-content .mbox-small, +.article-content .toc, +.article-content .catlinks, +.article-content .mw-authority-control, +.article-content .sistersitebox, +.article-content .portal, +.article-content .hatnote, +.article-content .dablink, +.article-content sup.reference, +.article-content .reflist, +.article-content .references, +.article-content .gallery, +.article-content .thumb, +.article-content figure, +.article-content .mw-empty-elt, +.article-content style, +.article-content link { + display: none !important; +} + +/* Definition lists */ +.article-content dl { + margin: 2rem 0; +} + +.article-content dt { + font-weight: 700; + color: var(--foreground); + margin-top: 1.25rem; + margin-bottom: 0.5rem; +} + +.article-content dd { + margin-left: 2rem; + margin-bottom: 1rem; + line-height: 1.8; +} + +/* First paragraph after heading - no top margin */ +.article-content h2 + p, +.article-content h3 + p, +.article-content h4 + p { + margin-top: 0; +} + +/* Smooth text rendering */ +.article-content { + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; +} + +/* ============================================ + ENHANCED ARTICLE EDITOR STYLES + ============================================ */ + +/* Editor Container */ +.article-editor-container { + display: flex; + flex-direction: column; + background: var(--card); + border: 1px solid var(--border); + border-radius: 0.75rem; + overflow: hidden; + box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -2px rgba(0, 0, 0, 0.1); +} + +.article-editor-container.editor-fullscreen { + position: fixed; + inset: 0; + z-index: 100; + border-radius: 0; + border: none; +} + +/* Recovery Banner */ +.recovery-banner { + display: flex; + align-items: center; + justify-content: space-between; + padding: 0.625rem 1rem; + background: oklch(0.95 0.08 250); + border-bottom: 1px solid oklch(0.8 0.12 250); + gap: 1rem; +} + +.dark .recovery-banner { + background: oklch(0.25 0.08 250); + border-color: oklch(0.4 0.1 250); +} + +.recovery-banner-content { + display: flex; + align-items: center; + gap: 0.5rem; + font-size: 0.875rem; + color: oklch(0.4 0.15 250); +} + +.dark .recovery-banner-content { + color: oklch(0.85 0.1 250); +} + +.recovery-banner-actions { + display: flex; + align-items: center; + gap: 0.5rem; +} + +/* Editor Header */ +.editor-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 0.625rem 1rem; + background: var(--muted); + border-bottom: 1px solid var(--border); + gap: 1rem; +} + +.editor-header-left { + display: flex; + align-items: center; + gap: 0.75rem; +} + +.editor-header-center { + flex: 1; + display: flex; + justify-content: center; +} + +.editor-header-right { + display: flex; + align-items: center; + gap: 0.5rem; +} + +.editor-status { + font-size: 0.75rem; + padding: 0.25rem 0.625rem; + border-radius: 9999px; +} + +.status-unsaved { + background: oklch(0.95 0.08 60); + color: oklch(0.45 0.15 60); +} + +.dark .status-unsaved { + background: oklch(0.3 0.08 60); + color: oklch(0.8 0.12 60); +} + +.status-saved { + background: oklch(0.95 0.05 145); + color: oklch(0.45 0.1 145); +} + +.dark .status-saved { + background: oklch(0.3 0.05 145); + color: oklch(0.8 0.1 145); +} + +.editor-stats { + font-size: 0.75rem; + color: var(--muted-foreground); +} + +/* Find & Replace Bar */ +.find-replace-bar { + display: flex; + align-items: center; + gap: 1rem; + padding: 0.5rem 1rem; + background: var(--muted); + border-bottom: 1px solid var(--border); +} + +.find-replace-inputs { + display: flex; + align-items: center; + gap: 0.5rem; + flex: 1; +} + +.find-input-group { + display: flex; + align-items: center; + flex: 1; + max-width: 300px; + background: var(--background); + border: 1px solid var(--border); + border-radius: 0.375rem; + overflow: hidden; +} + +.find-input, +.replace-input { + flex: 1; + padding: 0.375rem 0.625rem; + font-size: 0.8125rem; + border: none; + background: transparent; + color: var(--foreground); + outline: none; +} + +.replace-input { + max-width: 200px; + background: var(--background); + border: 1px solid var(--border); + border-radius: 0.375rem; +} + +.match-count { + font-size: 0.6875rem; + color: var(--muted-foreground); + padding: 0 0.5rem; + white-space: nowrap; +} + +.find-replace-actions { + display: flex; + align-items: center; + gap: 0.25rem; +} + +/* Enhanced Toolbar */ +.editor-toolbar-enhanced { + display: flex; + align-items: center; + gap: 0.25rem; + padding: 0.5rem 1rem; + background: var(--background); + border-bottom: 1px solid var(--border); + flex-wrap: wrap; +} + +.toolbar-group { + display: flex; + align-items: center; + gap: 0.125rem; +} + +.toolbar-divider { + width: 1px; + height: 1.5rem; + background: var(--border); + margin: 0 0.375rem; +} + +.toolbar-btn { + width: 2rem; + height: 2rem; + padding: 0; + display: flex; + align-items: center; + justify-content: center; + border-radius: 0.375rem; + transition: all 0.15s; +} + +.toolbar-btn:hover:not(:disabled) { + background: var(--accent); +} + +.toolbar-btn:active:not(:disabled) { + transform: scale(0.95); +} + +.heading-dropdown-container { + position: relative; +} + +.heading-btn { + width: auto; + padding: 0 0.5rem; +} + +.heading-dropdown { + position: absolute; + top: 100%; + left: 0; + margin-top: 0.25rem; + background: var(--popover); + border: 1px solid var(--border); + border-radius: 0.5rem; + box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -4px rgba(0, 0, 0, 0.1); + z-index: 50; + min-width: 150px; + overflow: hidden; +} + +.heading-option { + display: flex; + align-items: center; + gap: 0.625rem; + width: 100%; + padding: 0.625rem 0.875rem; + border: none; + background: transparent; + cursor: pointer; + color: var(--foreground); + font-size: 0.875rem; + text-align: left; + transition: background 0.15s; +} + +.heading-option:hover { + background: var(--accent); +} + +.insert-dropdown { + min-width: 180px; +} + +.dropdown-divider { + height: 1px; + background: var(--border); + margin: 0.375rem 0; +} + +/* Main Editor Area */ +.editor-main { + display: flex; + flex: 1; + min-height: 400px; + overflow: hidden; +} + +.editor-fullscreen .editor-main { + min-height: 0; + flex: 1; +} + +.editor-pane { + flex: 1; + display: flex; + flex-direction: column; + min-width: 0; +} + +.editor-main.with-preview .editor-pane { + flex: 1; + border-right: 1px solid var(--border); +} + +.editor-textarea-enhanced { + width: 100%; + height: 100%; + min-height: 400px; + padding: 1.25rem; + font-family: var(--font-sans); + font-size: 1rem; + line-height: 1.8; + background: var(--background); + border: none; + color: var(--foreground); + resize: none; + outline: none; +} + +.editor-fullscreen .editor-textarea-enhanced { + min-height: 0; +} + +.editor-textarea-enhanced:disabled { + opacity: 0.6; + cursor: not-allowed; +} + +.editor-textarea-enhanced::placeholder { + color: var(--muted-foreground); +} + +/* Preview Pane */ +.preview-pane { + flex: 1; + display: flex; + flex-direction: column; + min-width: 0; + background: var(--background); +} + +.preview-header { + display: flex; + align-items: center; + gap: 0.5rem; + padding: 0.625rem 1rem; + background: var(--muted); + border-bottom: 1px solid var(--border); + font-size: 0.75rem; + font-weight: 500; + color: var(--muted-foreground); + text-transform: uppercase; + letter-spacing: 0.05em; +} + +.preview-content { + flex: 1; + padding: 1.25rem; + overflow-y: auto; +} + +.editor-preview-content { + font-size: 1rem; + line-height: 1.8; +} + +.editor-preview-content p, +.editor-preview-content li { + font-size: 1rem; + line-height: 1.8; +} + +.editor-preview-content h1 { + font-size: 1.75rem; + margin-top: 1.5rem; + margin-bottom: 1rem; +} + +.editor-preview-content h2 { + font-size: 1.5rem; + margin-top: 1.5rem; + margin-bottom: 0.875rem; +} + +.editor-preview-content h3 { + font-size: 1.25rem; + margin-top: 1.25rem; + margin-bottom: 0.75rem; +} + +.editor-preview-empty { + color: var(--muted-foreground); + font-style: italic; +} + +/* Diff Preview */ +.diff-preview-container { + flex: 1; + display: flex; + flex-direction: column; + overflow: hidden; +} + +.diff-preview-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 0.625rem 1rem; + background: var(--muted); + border-bottom: 1px solid var(--border); +} + +.diff-preview-title { + font-size: 0.75rem; + font-weight: 500; + color: var(--muted-foreground); + text-transform: uppercase; + letter-spacing: 0.05em; +} + +.diff-preview-stats { + display: flex; + gap: 0.625rem; + font-size: 0.75rem; + font-weight: 600; +} + +.diff-stat-added { + color: oklch(0.5 0.18 145); +} + +.dark .diff-stat-added { + color: oklch(0.7 0.18 145); +} + +.diff-stat-removed { + color: oklch(0.55 0.2 25); +} + +.dark .diff-stat-removed { + color: oklch(0.7 0.18 25); +} + +.diff-preview-content { + flex: 1; + overflow-y: auto; + font-family: var(--font-mono); + font-size: 0.8125rem; + line-height: 1.5; +} + +.diff-preview-line { + display: flex; + min-height: 1.5rem; +} + +.diff-preview-gutter { + width: 1.5rem; + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + user-select: none; +} + +.diff-sign { + font-weight: 700; +} + +.diff-preview-added .diff-sign { + color: oklch(0.5 0.18 145); +} + +.dark .diff-preview-added .diff-sign { + color: oklch(0.7 0.18 145); +} + +.diff-preview-removed .diff-sign { + color: oklch(0.55 0.2 25); +} + +.dark .diff-preview-removed .diff-sign { + color: oklch(0.7 0.18 25); +} + +.diff-preview-line-num { + width: 2.5rem; + padding: 0 0.375rem; + text-align: right; + color: var(--muted-foreground); + background: var(--muted); + flex-shrink: 0; + user-select: none; + font-size: 0.6875rem; +} + +.diff-preview-code { + flex: 1; + padding: 0 0.625rem; + white-space: pre-wrap; + word-break: break-all; +} + +.diff-preview-unchanged { + background: var(--background); +} + +.diff-preview-added { + background: oklch(0.96 0.04 145); +} + +.dark .diff-preview-added { + background: oklch(0.22 0.05 145); +} + +.diff-preview-removed { + background: oklch(0.96 0.04 25); +} + +.dark .diff-preview-removed { + background: oklch(0.22 0.05 25); +} + +/* Keyboard Shortcuts Hint */ +.editor-shortcuts-hint { + display: flex; + align-items: center; + justify-content: center; + gap: 1.5rem; + padding: 0.5rem 1rem; + background: var(--muted); + border-top: 1px solid var(--border); + font-size: 0.6875rem; + color: var(--muted-foreground); +} + +.editor-shortcuts-hint span { + display: flex; + align-items: center; + gap: 0.25rem; +} + +/* Scrollbar styling for editor */ +.editor-textarea-enhanced::-webkit-scrollbar, +.preview-content::-webkit-scrollbar, +.diff-preview-content::-webkit-scrollbar { + width: 8px; +} + +.editor-textarea-enhanced::-webkit-scrollbar-track, +.preview-content::-webkit-scrollbar-track, +.diff-preview-content::-webkit-scrollbar-track { + background: transparent; +} + +.editor-textarea-enhanced::-webkit-scrollbar-thumb, +.preview-content::-webkit-scrollbar-thumb, +.diff-preview-content::-webkit-scrollbar-thumb { + background: var(--border); + border-radius: 4px; +} + +.editor-textarea-enhanced::-webkit-scrollbar-thumb:hover, +.preview-content::-webkit-scrollbar-thumb:hover, +.diff-preview-content::-webkit-scrollbar-thumb:hover { + background: var(--muted-foreground); +} + +/* Editor focus state */ +.editor-pane:focus-within { + box-shadow: inset 0 0 0 2px oklch(0.55 0.2 250 / 0.15); +} + +/* Smooth transitions */ +.toolbar-btn, +.editor-status, +.find-replace-bar, +.recovery-banner { + transition: all 0.15s ease; +} + +/* Active toolbar button state */ +.toolbar-btn:active:not(:disabled) { + background: var(--accent); +} + +/* Better focus states */ +.find-input:focus, +.replace-input:focus { + box-shadow: 0 0 0 2px oklch(0.55 0.2 250 / 0.2); +} + +/* Responsive design for mobile */ +@media (max-width: 768px) { + .editor-header { + flex-wrap: wrap; + padding: 0.5rem; + } + + .editor-header-center { + order: 3; + width: 100%; + justify-content: center; + padding-top: 0.5rem; + border-top: 1px solid var(--border); + margin-top: 0.5rem; + } + + .editor-main { + flex-direction: column; + } + + .editor-main.with-preview .editor-pane { + border-right: none; + border-bottom: 1px solid var(--border); + } + + .preview-pane { + max-height: 300px; + } + + .editor-toolbar-enhanced { + overflow-x: auto; + flex-wrap: nowrap; + -webkit-overflow-scrolling: touch; + } + + .toolbar-divider { + display: none; + } + + .editor-shortcuts-hint { + display: none; + } + + .find-replace-bar { + flex-direction: column; + gap: 0.5rem; + } + + .find-replace-inputs { + flex-direction: column; + width: 100%; + } + + .find-input-group, + .replace-input { + max-width: none; + width: 100%; + } +} + +/* Animation for status changes */ +@keyframes pulse-subtle { + 0%, 100% { + opacity: 1; + } + 50% { + opacity: 0.7; + } +} + +.status-unsaved { + animation: pulse-subtle 2s ease-in-out infinite; +} + +/* Legacy editor styles for compatibility */ +.article-editor { + display: flex; + flex-direction: column; + gap: 1rem; +} + +.editor-toolbar { + display: flex; + justify-content: space-between; + gap: 0.5rem; + padding-bottom: 1rem; + border-bottom: 1px solid var(--border); +} + +.editor-textarea { + width: 100%; + min-height: 500px; + padding: 1rem; + font-family: var(--font-mono); + font-size: 0.875rem; + line-height: 1.6; + background: var(--card); + border: 1px solid var(--border); + border-radius: 0.5rem; + color: var(--foreground); + resize: vertical; +} + +.editor-textarea:focus { + outline: none; + border-color: oklch(0.55 0.2 250); + box-shadow: 0 0 0 2px oklch(0.55 0.2 250 / 0.2); +} + +.editor-textarea:disabled { + opacity: 0.6; + cursor: not-allowed; +} + +/* Review Stream */ +.review-stream { + background: var(--muted); + border-radius: 0.5rem; + padding: 1rem; + margin-top: 1rem; +} + +.review-stream h4 { + font-weight: 600; + margin-bottom: 0.5rem; + color: var(--foreground); +} + +.review-stream-text { + font-size: 0.875rem; + color: var(--muted-foreground); + white-space: pre-wrap; + word-break: break-word; +} + +/* Review Result Panel */ +.review-panel { + display: flex; + gap: 1rem; + padding: 1.25rem; + border-radius: 0.75rem; + margin-top: 1rem; + align-items: flex-start; +} + +.review-approved { + background: oklch(0.95 0.05 145); + border: 1px solid oklch(0.7 0.15 145); +} + +.dark .review-approved { + background: oklch(0.25 0.05 145); + border-color: oklch(0.45 0.1 145); +} + +.review-rejected { + background: oklch(0.95 0.05 25); + border: 1px solid oklch(0.7 0.15 25); +} + +.dark .review-rejected { + background: oklch(0.25 0.05 25); + border-color: oklch(0.45 0.1 25); +} + +.review-icon { + flex-shrink: 0; + width: 2.5rem; + height: 2.5rem; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; +} + +.review-approved .review-icon { + background: oklch(0.6 0.2 145); + color: white; +} + +.review-rejected .review-icon { + background: oklch(0.6 0.2 25); + color: white; +} + +.review-content { + flex: 1; +} + +.review-title { + font-weight: 600; + font-size: 1rem; + margin-bottom: 0.25rem; +} + +.review-approved .review-title { + color: oklch(0.4 0.15 145); +} + +.dark .review-approved .review-title { + color: oklch(0.75 0.15 145); +} + +.review-rejected .review-title { + color: oklch(0.45 0.2 25); +} + +.dark .review-rejected .review-title { + color: oklch(0.75 0.15 25); +} + +.review-reason { + font-size: 0.875rem; + color: var(--foreground); + opacity: 0.9; +} + +.review-actions { + display: flex; + gap: 0.5rem; + flex-shrink: 0; +} + +/* History Modal */ +.history-modal-overlay { + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.5); + display: flex; + align-items: center; + justify-content: center; + z-index: 50; + padding: 1rem; +} + +.history-modal { + background: var(--card); + border-radius: 0.75rem; + width: 100%; + max-width: 1200px; + max-height: 90vh; + display: flex; + flex-direction: column; + box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.25); +} + +.history-modal-header { + display: flex; + justify-content: space-between; + align-items: center; + padding: 1rem 1.5rem; + border-bottom: 1px solid var(--border); +} + +.history-modal-header h2 { + font-size: 1.25rem; + font-weight: 600; +} + +.history-modal-body { + flex: 1; + overflow: hidden; + display: flex; + min-height: 400px; +} + +.history-loading, +.history-empty { + display: flex; + align-items: center; + justify-content: center; + width: 100%; + color: var(--muted-foreground); + padding: 2rem; +} + +.history-list { + width: 280px; + border-right: 1px solid var(--border); + overflow-y: auto; + flex-shrink: 0; +} + +.history-item { + display: block; + width: 100%; + padding: 1rem; + text-align: left; + border: none; + background: transparent; + border-bottom: 1px solid var(--border); + cursor: pointer; + transition: background 0.15s; +} + +.history-item:hover { + background: var(--accent); +} + +.history-item.selected { + background: var(--accent); +} + +.history-item-date { + font-size: 0.75rem; + color: var(--muted-foreground); + margin-bottom: 0.25rem; +} + +.history-item-reason { + font-size: 0.875rem; + color: var(--foreground); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.history-diff { + flex: 1; + overflow: auto; + padding: 1rem; +} + +/* Diff Viewer */ +.diff-viewer { + font-family: var(--font-mono); + font-size: 0.75rem; + line-height: 1.5; + border: 1px solid var(--border); + border-radius: 0.5rem; + overflow: hidden; +} + +.diff-header { + display: flex; + background: var(--muted); + border-bottom: 1px solid var(--border); +} + +.diff-column-header { + flex: 1; + padding: 0.5rem 1rem; + font-weight: 600; + color: var(--foreground); +} + +.diff-column-header:first-child { + border-right: 1px solid var(--border); +} + +.diff-content { + display: flex; + overflow: auto; +} + +.diff-column { + flex: 1; + min-width: 0; +} + +.diff-old { + border-right: 1px solid var(--border); +} + +.diff-line { + display: flex; + min-height: 1.5rem; +} + +.diff-line-num { + width: 3rem; + padding: 0 0.5rem; + text-align: right; + color: var(--muted-foreground); + background: var(--muted); + flex-shrink: 0; + user-select: none; +} + +.diff-line-content { + flex: 1; + padding: 0 0.5rem; + white-space: pre-wrap; + word-break: break-all; +} + +.diff-added { + background: oklch(0.95 0.05 145); +} + +.dark .diff-added { + background: oklch(0.25 0.06 145); +} + +.diff-added .diff-line-num { + background: oklch(0.9 0.08 145); +} + +.dark .diff-added .diff-line-num { + background: oklch(0.3 0.08 145); +} + +.diff-removed { + background: oklch(0.95 0.05 25); +} + +.dark .diff-removed { + background: oklch(0.25 0.06 25); +} + +.diff-removed .diff-line-num { + background: oklch(0.9 0.08 25); +} + +.dark .diff-removed .diff-line-num { + background: oklch(0.3 0.08 25); } diff --git a/app/layout.tsx b/app/layout.tsx index f7fa87e..326aff3 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -1,6 +1,10 @@ import type { Metadata } from "next"; import { Geist, Geist_Mono } from "next/font/google"; import "./globals.css"; +import { config } from '@fortawesome/fontawesome-svg-core'; +import '@fortawesome/fontawesome-svg-core/styles.css'; + +config.autoAddCss = false; const geistSans = Geist({ variable: "--font-geist-sans", @@ -13,8 +17,8 @@ const geistMono = Geist_Mono({ }); export const metadata: Metadata = { - title: "Create Next App", - description: "Generated by create next app", + title: "Wiki+", + description: "Discover random knowledge from Wikipedia", }; export default function RootLayout({ @@ -23,7 +27,7 @@ export default function RootLayout({ children: React.ReactNode; }>) { return ( - + diff --git a/app/page.tsx b/app/page.tsx index 295f8fd..b01f1e8 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -1,65 +1,65 @@ -import Image from "next/image"; +'use client'; + +import { useState, FormEvent } from 'react'; +import { useRouter } from 'next/navigation'; +import { PageTransition } from '@/components/page-transition'; +import { Search, Shuffle } from 'lucide-react'; export default function Home() { + const [query, setQuery] = useState(''); + const router = useRouter(); + + const handleSearch = (e: FormEvent) => { + e.preventDefault(); + if (query.trim()) { + router.push(`/search?query=${encodeURIComponent(query.trim())}`); + } + }; + return ( - + + ); } diff --git a/app/random/route.ts b/app/random/route.ts new file mode 100644 index 0000000..0cfc2a3 --- /dev/null +++ b/app/random/route.ts @@ -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) + ); + } +} diff --git a/app/search/page.tsx b/app/search/page.tsx new file mode 100644 index 0000000..b12b98e --- /dev/null +++ b/app/search/page.tsx @@ -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([]); + const [loading, setLoading] = useState(false); + const [searched, setSearched] = useState(false); + const lastSearchedQuery = useRef(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 ( +
+ {/* Clean header */} +
+
+
+ + + + +
+
+ + 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 && ( + + )} +
+
+
+
+
+ + {/* Results area */} +
+ {loading ? ( +
+ {[...Array(5)].map((_, i) => ( +
+
+
+
+
+
+
+
+ ))} +
+ ) : results.length > 0 ? ( +
+

+ {results.length} result{results.length !== 1 ? 's' : ''} for "{initialQuery}" +

+
+ {results.map((result, index) => ( + +
+ {result.thumbnail ? ( +
+ +
+ ) : ( +
+ +
+ )} +
+

+ {result.title} +

+ {result.description && ( +

+ {result.description} +

+ )} +
+
+ + ))} +
+
+ ) : searched ? ( +
+
+ +
+

+ No results found for "{initialQuery}" +

+

+ Try different keywords or check the spelling +

+
+ ) : ( +
+
+ +
+

+ Enter a search term to find articles +

+
+ )} +
+ + +
+ ); +} + +export default function SearchPage() { + return ( + +
+
+ Loading... +
+ + }> + + + ); +} diff --git a/app/summary/[title]/page.tsx b/app/summary/[title]/page.tsx new file mode 100644 index 0000000..8f1b5b1 --- /dev/null +++ b/app/summary/[title]/page.tsx @@ -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 { + 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 ( +
+
+
+ + Wiki+ + +

+ {article.title} +

+
+ + {imageUrl && ( +
+ {article.title} +
+ )} + +
+

+ {article.extract} +

+
+ +
+ + Full article + + + + + +
+
+
+ ); +} diff --git a/components.json b/components.json new file mode 100644 index 0000000..b7b9791 --- /dev/null +++ b/components.json @@ -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": {} +} diff --git a/components/article-editor.tsx b/components/article-editor.tsx new file mode 100644 index 0000000..9915efb --- /dev/null +++ b/components/article-editor.tsx @@ -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(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([initialContent]); + const [redoStack, setRedoStack] = useState([]); + const [findReplace, setFindReplace] = useState({ + isOpen: false, + findText: '', + replaceText: '', + matchCount: 0, + currentMatch: 0, + }); + + const textareaRef = useRef(null); + const previewRef = useRef(null); + const editorContainerRef = useRef(null); + const lastSavedContent = useRef(initialContent); + const isScrollingSyncRef = useRef(false); + const autoSaveTimeoutRef = useRef(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(//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 ( +
+ {/* Recovery banner */} + {showRecoveryBanner && ( +
+
+ + A previous draft was found. Would you like to recover it? +
+
+ + +
+
+ )} + + {/* Header bar */} +
+
+ +
+ {hasChanges ? ( + + Unsaved changes + {autoSaveStatus === 'saving' && ' (saving...)'} + {autoSaveStatus === 'saved' && ' (draft saved)'} + + ) : ( + No changes + )} +
+
+ +
+ + {wordCount.toLocaleString()} words · {charCount.toLocaleString()} chars · {lineCount.toLocaleString()} lines + +
+ +
+ + + + + +
+
+ + {/* Find & Replace bar */} + {findReplace.isOpen && ( +
+
+
+ setFindReplace(prev => ({ ...prev, findText: e.target.value }))} + className="find-input" + autoFocus + /> + {findReplace.matchCount > 0 && ( + + {findReplace.currentMatch} of {findReplace.matchCount} + + )} +
+ setFindReplace(prev => ({ ...prev, replaceText: e.target.value }))} + className="replace-input" + /> +
+
+ + + + +
+
+ )} + + {/* Toolbar */} + + + {/* Main editor area */} +
+ {showDiff && hasChanges ? ( + + ) : ( + <> +
+