a new start

This commit is contained in:
valknarness
2025-10-25 16:09:02 +02:00
commit b63592f153
94 changed files with 23058 additions and 0 deletions

View File

@@ -0,0 +1,49 @@
import { NextResponse } from 'next/server';
import { readFileSync, existsSync, statSync } from 'fs';
import { join } from 'path';
import { createHash } from 'crypto';
export const dynamic = 'force-dynamic';
// Get database version and metadata
export async function GET() {
try {
// Use the database from the user's home directory
const homeDir = process.env.HOME || process.env.USERPROFILE || '';
const dbPath = join(homeDir, '.awesome', 'awesome.db');
const metadataPath = join(homeDir, '.awesome', 'db-metadata.json');
if (!existsSync(dbPath)) {
return NextResponse.json(
{ error: 'Database not found' },
{ status: 404 }
);
}
// Get file stats
const stats = statSync(dbPath);
// Calculate hash for version
const buffer = readFileSync(dbPath);
const hash = createHash('sha256').update(buffer).digest('hex');
// Load metadata if available
let metadata = {};
if (existsSync(metadataPath)) {
metadata = JSON.parse(readFileSync(metadataPath, 'utf-8'));
}
return NextResponse.json({
version: hash.substring(0, 16),
size: stats.size,
modified: stats.mtime.toISOString(),
...metadata,
});
} catch (error) {
console.error('Error getting DB version:', error);
return NextResponse.json(
{ error: 'Internal server error' },
{ status: 500 }
);
}
}

View File

@@ -0,0 +1,50 @@
import { NextRequest, NextResponse } from 'next/server'
import { getRepositoriesByList, getAwesomeLists } from '@/lib/db'
export async function GET(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const { id } = await params
const listId = parseInt(id, 10)
if (isNaN(listId)) {
return NextResponse.json(
{ error: 'Invalid list ID' },
{ status: 400 }
)
}
// Get the list info
const lists = getAwesomeLists()
const list = lists.find(l => l.id === listId)
if (!list) {
return NextResponse.json(
{ error: 'List not found' },
{ status: 404 }
)
}
// Parse pagination
const searchParams = request.nextUrl.searchParams
const page = parseInt(searchParams.get('page') || '1', 10)
const limit = parseInt(searchParams.get('limit') || '50', 10)
const offset = (page - 1) * limit
// Get repositories
const repositories = getRepositoriesByList(listId, limit, offset)
return NextResponse.json({
list,
repositories
})
} catch (error) {
console.error('List detail API error:', error)
return NextResponse.json(
{ error: 'Internal server error' },
{ status: 500 }
)
}
}

24
app/api/lists/route.ts Normal file
View File

@@ -0,0 +1,24 @@
import { NextRequest, NextResponse } from 'next/server'
import { getAwesomeLists, getCategories } from '@/lib/db'
export async function GET(request: NextRequest) {
try {
const searchParams = request.nextUrl.searchParams
const category = searchParams.get('category') || undefined
const lists = getAwesomeLists(category)
const categories = getCategories()
return NextResponse.json({
lists,
categories,
total: lists.length
})
} catch (error) {
console.error('Lists API error:', error)
return NextResponse.json(
{ error: 'Internal server error' },
{ status: 500 }
)
}
}

View File

@@ -0,0 +1,36 @@
import { NextRequest, NextResponse } from 'next/server'
import { getRepositoryWithReadme } from '@/lib/db'
export async function GET(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const { id } = await params
const repositoryId = parseInt(id, 10)
if (isNaN(repositoryId)) {
return NextResponse.json(
{ error: 'Invalid repository ID' },
{ status: 400 }
)
}
const repository = getRepositoryWithReadme(repositoryId)
if (!repository) {
return NextResponse.json(
{ error: 'Repository not found' },
{ status: 404 }
)
}
return NextResponse.json(repository)
} catch (error) {
console.error('Repository API error:', error)
return NextResponse.json(
{ error: 'Internal server error' },
{ status: 500 }
)
}
}

48
app/api/search/route.ts Normal file
View File

@@ -0,0 +1,48 @@
import { NextRequest, NextResponse } from 'next/server'
import { searchRepositories } from '@/lib/db'
export async function GET(request: NextRequest) {
try {
const searchParams = request.nextUrl.searchParams
const query = searchParams.get('q')
if (!query) {
return NextResponse.json(
{ error: 'Query parameter "q" is required' },
{ status: 400 }
)
}
// Parse pagination
const page = parseInt(searchParams.get('page') || '1', 10)
const limit = parseInt(searchParams.get('limit') || '20', 10)
const offset = (page - 1) * limit
// Parse filters
const language = searchParams.get('language') || undefined
const category = searchParams.get('category') || undefined
const minStars = searchParams.get('minStars')
? parseInt(searchParams.get('minStars')!, 10)
: undefined
const sortBy = (searchParams.get('sortBy') || 'relevance') as 'relevance' | 'stars' | 'recent'
// Perform search
const results = searchRepositories({
query,
limit,
offset,
language,
minStars,
category,
sortBy
})
return NextResponse.json(results)
} catch (error) {
console.error('Search API error:', error)
return NextResponse.json(
{ error: 'Internal server error' },
{ status: 500 }
)
}
}

24
app/api/stats/route.ts Normal file
View File

@@ -0,0 +1,24 @@
import { NextResponse } from 'next/server'
import { getStats, getLanguages, getCategories, getTrendingRepositories } from '@/lib/db'
export async function GET() {
try {
const stats = getStats()
const languages = getLanguages()
const categories = getCategories()
const trending = getTrendingRepositories(10)
return NextResponse.json({
stats,
languages,
categories,
trending
})
} catch (error) {
console.error('Stats API error:', error)
return NextResponse.json(
{ error: 'Internal server error' },
{ status: 500 }
)
}
}

60
app/api/webhook/route.ts Normal file
View File

@@ -0,0 +1,60 @@
import { NextRequest, NextResponse } from 'next/server';
import { createHmac } from 'crypto';
import { writeFileSync } from 'fs';
import { join } from 'path';
export const dynamic = 'force-dynamic';
// Verify webhook signature
function verifySignature(payload: string, signature: string, secret: string): boolean {
const hmac = createHmac('sha256', secret);
const digest = 'sha256=' + hmac.update(payload).digest('hex');
return digest === signature;
}
// Handle webhook from GitHub Actions
export async function POST(request: NextRequest) {
try {
const signature = request.headers.get('x-github-secret');
const body = await request.text();
// Verify signature if secret is configured
const webhookSecret = process.env.WEBHOOK_SECRET;
if (webhookSecret && signature) {
if (!verifySignature(body, signature, webhookSecret)) {
return NextResponse.json(
{ error: 'Invalid signature' },
{ status: 401 }
);
}
}
const data = JSON.parse(body);
console.log('📥 Webhook received:', {
version: data.version,
timestamp: data.timestamp,
lists: data.lists_count,
repos: data.repos_count,
});
// Save metadata
const metadataPath = join(process.cwd(), 'data', 'db-metadata.json');
writeFileSync(metadataPath, JSON.stringify(data, null, 2));
// TODO: Trigger database download from hosting
// const dbUrl = process.env.DB_URL;
// await downloadDatabase(dbUrl);
return NextResponse.json({
success: true,
message: 'Database metadata updated',
});
} catch (error) {
console.error('Webhook error:', error);
return NextResponse.json(
{ error: 'Webhook processing failed' },
{ status: 500 }
);
}
}

202
app/browse/page.tsx Normal file
View File

@@ -0,0 +1,202 @@
'use client'
import * as React from 'react'
import Link from 'next/link'
import { Folder, ChevronRight } from 'lucide-react'
import { Input } from '@/components/ui/input'
import { Badge } from '@/components/ui/badge'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { Skeleton } from '@/components/ui/skeleton'
interface AwesomeList {
id: number
name: string
url: string
description: string | null
category: string | null
stars: number | null
}
interface Category {
name: string
count: number
}
interface BrowseResponse {
lists: AwesomeList[]
categories: Category[]
total: number
}
export default function BrowsePage() {
const [data, setData] = React.useState<BrowseResponse | null>(null)
const [loading, setLoading] = React.useState(true)
const [searchQuery, setSearchQuery] = React.useState('')
const [selectedCategory, setSelectedCategory] = React.useState<string>('')
React.useEffect(() => {
const params = new URLSearchParams()
if (selectedCategory) {
params.set('category', selectedCategory)
}
fetch(`/api/lists?${params}`)
.then(res => res.json())
.then(data => {
setData(data)
setLoading(false)
})
.catch(err => {
console.error('Failed to fetch lists:', err)
setLoading(false)
})
}, [selectedCategory])
const filteredLists = React.useMemo(() => {
if (!data) return []
let filtered = data.lists
if (searchQuery) {
const query = searchQuery.toLowerCase()
filtered = filtered.filter(list =>
list.name.toLowerCase().includes(query) ||
list.description?.toLowerCase().includes(query) ||
list.category?.toLowerCase().includes(query)
)
}
return filtered
}, [data, searchQuery])
// Group lists by category
const groupedLists = React.useMemo(() => {
const groups: Record<string, AwesomeList[]> = {}
filteredLists.forEach(list => {
const category = list.category || 'Uncategorized'
if (!groups[category]) {
groups[category] = []
}
groups[category].push(list)
})
// Sort categories by name
return Object.keys(groups)
.sort()
.reduce((acc, key) => {
acc[key] = groups[key].sort((a, b) => (b.stars || 0) - (a.stars || 0))
return acc
}, {} as Record<string, AwesomeList[]>)
}, [filteredLists])
return (
<div className="min-h-screen bg-linear-to-br from-background via-background to-primary/5">
{/* Header */}
<div className="border-b bg-background/80 backdrop-blur-sm">
<div className="mx-auto max-w-7xl px-6 py-6">
<h1 className="gradient-text mb-4 text-3xl font-bold">Browse Collections</h1>
<p className="mb-6 text-muted-foreground">
Explore {data?.total || '...'} curated awesome lists organized by category
</p>
{/* Filters */}
<div className="flex flex-col gap-4 sm:flex-row">
<div className="flex-1">
<Input
type="search"
placeholder="Search lists..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
/>
</div>
<div className="w-full sm:w-[200px]">
<Select value={selectedCategory || 'all'} onValueChange={(value) => setSelectedCategory(value === 'all' ? '' : value)}>
<SelectTrigger>
<SelectValue placeholder="All categories" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All categories</SelectItem>
{data?.categories.map(cat => (
<SelectItem key={cat.name} value={cat.name}>
{cat.name} ({cat.count})
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
</div>
</div>
{/* Content */}
<div className="mx-auto max-w-7xl px-6 py-8">
{loading && (
<div className="space-y-8">
{[...Array(3)].map((_, i) => (
<div key={i}>
<Skeleton className="mb-4 h-8 w-48" />
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{[...Array(6)].map((_, j) => (
<Skeleton key={j} className="h-32" />
))}
</div>
</div>
))}
</div>
)}
{!loading && Object.keys(groupedLists).length === 0 && (
<div className="py-12 text-center">
<p className="text-lg text-muted-foreground">
No lists found matching your criteria
</p>
</div>
)}
{!loading && Object.keys(groupedLists).length > 0 && (
<div className="space-y-12">
{Object.entries(groupedLists).map(([category, lists]) => (
<section key={category}>
<div className="mb-4 flex items-center gap-3">
<Folder className="h-6 w-6 text-primary" />
<h2 className="text-2xl font-bold">{category}</h2>
<Badge variant="secondary">{lists.length}</Badge>
</div>
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{lists.map(list => (
<Link
key={list.id}
href={`/list/${list.id}`}
className="card-awesome group block rounded-lg bg-card p-6 transition-all"
>
<div className="mb-3 flex items-start justify-between gap-2">
<h3 className="font-semibold text-primary group-hover:text-primary/80">
{list.name}
</h3>
<ChevronRight className="h-5 w-5 text-muted-foreground transition-transform group-hover:translate-x-1" />
</div>
{list.description && (
<p className="line-clamp-2 text-sm text-muted-foreground">
{list.description}
</p>
)}
</Link>
))}
</div>
</section>
))}
</div>
)}
</div>
</div>
)
}

209
app/disclaimer/page.tsx Normal file
View File

@@ -0,0 +1,209 @@
import Link from 'next/link'
import { ArrowLeft, AlertTriangle } from 'lucide-react'
import { Button } from '@/components/ui/button'
export const metadata = {
title: 'Disclaimer | Awesome',
description: 'Important disclaimers and information',
}
export default function DisclaimerPage() {
return (
<div className="min-h-screen bg-linear-to-br from-background via-background to-primary/5 px-6 py-12">
<div className="mx-auto max-w-4xl">
{/* Back Button */}
<Button asChild variant="ghost" className="mb-8">
<Link href="/">
<ArrowLeft className="mr-2 h-4 w-4" />
Back to Home
</Link>
</Button>
{/* Content */}
<article className="prose prose-lg dark:prose-invert max-w-none">
<div className="mb-6 flex items-center gap-3">
<AlertTriangle className="h-8 w-8 text-accent" />
<h1 className="gradient-text mb-0">Disclaimer</h1>
</div>
<p className="lead">
Important information about using Awesome and the content displayed on this
platform.
</p>
<h2>General Disclaimer</h2>
<p>
The information provided by Awesome (&quot;we&quot;, &quot;us&quot;, or &quot;our&quot;) is for general
informational purposes only. All information on the site is provided in good
faith, however we make no representation or warranty of any kind, express or
implied, regarding the accuracy, adequacy, validity, reliability,
availability, or completeness of any information on the site.
</p>
<h2>Third-Party Content</h2>
<h3>Aggregated Information</h3>
<p>
Awesome displays content aggregated from various GitHub repositories,
primarily from the{' '}
<a
href="https://github.com/sindresorhus/awesome"
target="_blank"
rel="noopener noreferrer"
>
sindresorhus/awesome
</a>{' '}
project and related awesome lists. We are not the authors or maintainers of
these lists.
</p>
<h3>Content Accuracy</h3>
<p>
While we strive to keep the information up to date and correct through
automated updates every 6 hours, we make no guarantees about:
</p>
<ul>
<li>The accuracy of repository information</li>
<li>The availability of linked resources</li>
<li>The quality or security of listed projects</li>
<li>The current maintenance status of repositories</li>
<li>The licensing terms of listed projects</li>
</ul>
<h3>No Endorsement</h3>
<p>
The inclusion of any repository, project, or resource on Awesome does not
constitute an endorsement, recommendation, or approval by us. We do not
verify the quality, security, or reliability of any listed content.
</p>
<h2>External Links</h2>
<p>
Awesome contains links to external websites and resources. These links are
provided solely for your convenience. We have no control over:
</p>
<ul>
<li>The content of linked websites</li>
<li>The privacy practices of external sites</li>
<li>The availability of external resources</li>
<li>The security of third-party platforms</li>
</ul>
<p>
We are not responsible for any content, products, services, or other
materials available on or through these external links.
</p>
<h2>Professional Disclaimer</h2>
<h3>No Professional Advice</h3>
<p>
The content on Awesome is not intended to be a substitute for professional
advice. Always seek the advice of qualified professionals with any questions
you may have regarding:
</p>
<ul>
<li>Software development decisions</li>
<li>Security implementations</li>
<li>Technology choices</li>
<li>License compatibility</li>
<li>Production deployments</li>
</ul>
<h3>Security Considerations</h3>
<p>
Before using any software or tool listed on Awesome, you should:
</p>
<ul>
<li>Review the source code and documentation</li>
<li>Check for known security vulnerabilities</li>
<li>Verify the license terms</li>
<li>Assess the maintenance status</li>
<li>Test thoroughly in a safe environment</li>
</ul>
<h2>Availability and Updates</h2>
<h3>Service Availability</h3>
<p>
We do not guarantee that Awesome will be available at all times. Technical
issues, maintenance, or other factors may cause temporary unavailability.
</p>
<h3>Data Currency</h3>
<p>
While our database updates every 6 hours via GitHub Actions, there may be
delays or gaps in updates due to:
</p>
<ul>
<li>GitHub API rate limits</li>
<li>Build failures</li>
<li>Network issues</li>
<li>Service interruptions</li>
</ul>
<h2>Limitation of Liability</h2>
<p>
Under no circumstances shall Awesome, its operators, contributors, or
affiliates be liable for any direct, indirect, incidental, consequential, or
special damages arising out of or in any way connected with your use of this
service, including but not limited to:
</p>
<ul>
<li>Use of any listed software or tools</li>
<li>Reliance on information provided</li>
<li>Security incidents or vulnerabilities</li>
<li>Data loss or corruption</li>
<li>Business interruption</li>
<li>Loss of profits or revenue</li>
</ul>
<h2>User Responsibility</h2>
<p>As a user of Awesome, you acknowledge and agree that:</p>
<ul>
<li>You use this service at your own risk</li>
<li>
You are responsible for evaluating the suitability of any listed content
</li>
<li>
You will verify information before making important decisions
</li>
<li>You will respect the licenses and terms of listed projects</li>
<li>
You understand that information may be outdated or incomplete
</li>
</ul>
<h2>Changes to This Disclaimer</h2>
<p>
We reserve the right to modify this disclaimer at any time. Changes will be
effective immediately upon posting to this page. Your continued use of
Awesome following any changes constitutes acceptance of those changes.
</p>
<h2>Contact</h2>
<p>
If you have any questions or concerns about this disclaimer, please open an
issue on our GitHub repository.
</p>
<hr />
<p className="text-sm text-muted-foreground">
Last updated: {new Date().toLocaleDateString('en-US', {
year: 'numeric',
month: 'long',
day: 'numeric',
})}
</p>
</article>
</div>
</div>
)
}

394
app/globals.css Normal file
View File

@@ -0,0 +1,394 @@
@import "tailwindcss";
@import "tw-animate-css";
@plugin "@tailwindcss/typography";
@plugin "tailwindcss-animate";
@custom-variant dark (&:is(.dark *));
/* Awesome Gradient Text - Dynamic theme support */
.gradient-text, .prose h1 {
background: var(--gradient-awesome);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
.gradient-text-pink, .prose h2 {
background: linear-gradient(135deg, var(--theme-secondary-dark) 0%, var(--theme-primary) 50%, var(--theme-primary-dark) 100%);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
.gradient-text-gold {
background: linear-gradient(135deg, var(--theme-accent) 0%, var(--theme-secondary) 50%, var(--theme-secondary-dark) 100%);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
/* Awesome Button - Dynamic theme support */
.btn-awesome {
@apply relative overflow-hidden rounded-lg px-6 py-3 font-semibold text-white transition-all duration-300;
background: var(--gradient-awesome);
box-shadow: 0 4px 15px 0 color-mix(in oklab, var(--primary) 40%, transparent);
}
.bg-gradient-awesome {
background: var(--gradient-awesome);
}
.btn-awesome:hover {
transform: translateY(-2px);
box-shadow: 0 6px 20px 0 color-mix(in oklab, var(--primary) 60%, transparent);
}
.btn-awesome:active {
transform: translateY(0);
}
/* Awesome Icon - Smooth theme transitions */
.awesome-icon {
transition: all 0.3s ease-in-out;
}
.awesome-icon path,
.awesome-icon circle {
transition: fill 0.3s ease-in-out;
}
/* Gradient Stroke Icons */
.icon-gradient-primary {
stroke: url(#gradient-primary);
}
.icon-gradient-secondary {
stroke: url(#gradient-secondary);
}
.icon-gradient-accent {
stroke: url(#gradient-accent);
}
.icon-gradient-awesome {
stroke: url(#gradient-awesome);
}
/* Awesome Card */
.card-awesome {
@apply rounded-lg border-2 transition-all duration-300;
border-color: color-mix(in oklab, var(--primary) 20%, transparent);
}
.card-awesome:hover {
border-color: color-mix(in oklab, var(--primary) 60%, transparent);
box-shadow: 0 8px 30px color-mix(in oklab, var(--primary) 30%, transparent);
transform: translateY(-2px);
}
/* Shimmer Effect */
.shimmer {
background: linear-gradient(
90deg,
rgba(255, 255, 255, 0) 0%,
rgba(255, 255, 255, 0.2) 20%,
rgba(255, 255, 255, 0.5) 60%,
rgba(255, 255, 255, 0)
);
background-size: 1000px 100%;
animation: shimmer 2s linear infinite;
}
/* Scrollbar Styling */
::-webkit-scrollbar {
width: 10px;
height: 10px;
}
::-webkit-scrollbar-track {
background: color-mix(in oklab, var(--foreground) 5%, transparent);
border-radius: 5px;
}
::-webkit-scrollbar-thumb {
background: var(--gradient-awesome);
border-radius: 5px;
}
::-webkit-scrollbar-thumb:hover {
background: linear-gradient(135deg, var(--theme-secondary-dark) 0%, var(--theme-primary) 50%, var(--theme-primary-dark) 100%);
}
/* Code Block Styling */
pre {
@apply rounded-lg border border-primary/20 bg-gray-50 dark:bg-gray-900;
padding: 1.5rem;
overflow-x: auto;
}
pre code {
@apply text-sm;
background: none !important;
padding: 0 !important;
}
/* Prose/Typography Customization for README */
.prose {
--tw-prose-body: var(--foreground);
--tw-prose-headings: var(--foreground);
--tw-prose-links: var(--primary);
--tw-prose-bold: var(--foreground);
--tw-prose-code: var(--primary);
--tw-prose-pre-bg: var(--muted);
--tw-prose-quotes: var(--muted-foreground);
--tw-prose-quote-borders: var(--primary);
--tw-prose-hr: var(--border);
--tw-prose-th-borders: var(--border);
--tw-prose-td-borders: var(--border);
}
.prose h1 {
@apply border-b border-border pb-2 mt-8 mb-4 text-4xl font-bold;
}
.prose h2 {
@apply border-b border-border/50 pb-2 mt-6 mb-3 text-3xl font-semibold;
}
.prose h3 {
@apply text-primary mt-5 mb-2 text-2xl font-semibold;
}
.prose h4 {
@apply text-foreground mt-4 mb-2 text-xl font-semibold;
}
.prose a {
@apply text-primary underline decoration-primary/30 underline-offset-2 transition-colors hover:text-primary/80 hover:decoration-primary/60;
}
.prose code {
@apply rounded bg-primary/10 px-1.5 py-0.5 text-sm font-mono text-primary;
}
.prose pre {
@apply rounded-lg border border-primary/20 bg-muted p-4 overflow-x-auto;
}
.prose pre code {
@apply bg-transparent p-0 text-sm;
}
.prose blockquote {
@apply border-l-4 border-primary pl-4 italic text-muted-foreground;
}
.prose table {
@apply w-full border-collapse;
}
.prose th {
@apply border border-border bg-muted px-4 py-2 text-left font-semibold;
}
.prose td {
@apply border border-border px-4 py-2;
}
.prose img {
@apply rounded-lg border border-border shadow-sm;
}
.prose ul, .prose ol {
@apply my-4;
}
.prose li {
@apply my-2;
}
.prose hr {
@apply my-8 border-border;
}
/* Kbd Styling */
kbd {
@apply inline-flex items-center justify-center rounded border border-primary/30 bg-primary/10 px-2 py-1 font-mono text-xs font-semibold text-primary;
box-shadow: 0 2px 0 0 color-mix(in oklab, var(--primary) 20%, transparent);
}
/* Selection */
::selection {
background: color-mix(in oklab, var(--primary) 30%, transparent);
color: inherit;
}
/* Focus Ring */
*:focus-visible {
@apply outline-none ring-2 ring-primary ring-offset-2;
}
/* Loading Spinner */
@keyframes spin-awesome {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
.spinner-awesome {
border: 3px solid color-mix(in oklab, var(--primary) 10%, transparent);
border-top-color: var(--primary);
border-radius: 50%;
animation: spin-awesome 0.8s linear infinite;
}
@theme inline {
--radius-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) + 4px);
/* Tailwind v4 theme color definitions */
--color-background: var(--background);
--color-foreground: var(--foreground);
--color-card: var(--card);
--color-card-foreground: var(--card-foreground);
--color-popover: var(--popover);
--color-popover-foreground: var(--popover-foreground);
--color-primary: var(--primary);
--color-primary-foreground: var(--primary-foreground);
--color-secondary: var(--secondary);
--color-secondary-foreground: var(--secondary-foreground);
--color-muted: var(--muted);
--color-muted-foreground: var(--muted-foreground);
--color-accent: var(--accent);
--color-accent-foreground: var(--accent-foreground);
--color-destructive: var(--destructive);
--color-destructive-foreground: var(--destructive-foreground);
--color-border: var(--border);
--color-input: var(--input);
--color-ring: var(--ring);
--color-chart-1: var(--chart-1);
--color-chart-2: var(--chart-2);
--color-chart-3: var(--chart-3);
--color-chart-4: var(--chart-4);
--color-chart-5: var(--chart-5);
--color-sidebar: var(--sidebar);
--color-sidebar-foreground: var(--sidebar-foreground);
--color-sidebar-primary: var(--sidebar-primary);
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
--color-sidebar-accent: var(--sidebar-accent);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-ring: var(--sidebar-ring);
}
:root {
--radius: 0.625rem;
/* Base colors in OKLCH */
--background: oklch(1 0 0);
--foreground: oklch(0.2 0.01 286);
--card: oklch(1 0 0);
--card-foreground: oklch(0.2 0.01 286);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.2 0.01 286);
/* Awesome Purple - Primary */
--primary: oklch(0.62 0.28 310);
--primary-foreground: oklch(0.98 0.01 286);
/* Awesome Pink - Secondary */
--secondary: oklch(0.72 0.19 345);
--secondary-foreground: oklch(0.15 0.01 286);
/* Muted colors */
--muted: oklch(0.96 0.005 286);
--muted-foreground: oklch(0.5 0.02 286);
/* Awesome Gold - Accent */
--accent: oklch(0.88 0.18 95);
--accent-foreground: oklch(0.15 0.01 286);
/* Destructive */
--destructive: oklch(0.62 0.25 25);
--destructive-foreground: oklch(0.98 0.01 286);
/* Borders and inputs */
--border: oklch(0.9 0.005 286);
--input: oklch(0.9 0.005 286);
--ring: oklch(0.62 0.28 310);
/* Chart colors */
--chart-1: oklch(0.7 0.19 35);
--chart-2: oklch(0.65 0.15 200);
--chart-3: oklch(0.5 0.12 250);
--chart-4: oklch(0.85 0.16 100);
--chart-5: oklch(0.8 0.18 80);
/* Sidebar */
--sidebar: oklch(0.98 0.005 286);
--sidebar-foreground: oklch(0.2 0.01 286);
--sidebar-primary: oklch(0.62 0.28 310);
--sidebar-primary-foreground: oklch(0.98 0.01 286);
--sidebar-accent: oklch(0.96 0.005 286);
--sidebar-accent-foreground: oklch(0.2 0.01 286);
--sidebar-border: oklch(0.9 0.005 286);
--sidebar-ring: oklch(0.62 0.28 310);
/* Dynamic theme colors (set by ThemeSwitcher) - converted to OKLCH */
--theme-primary: oklch(0.62 0.28 310);
--theme-primary-light: oklch(0.70 0.27 310);
--theme-primary-dark: oklch(0.54 0.26 295);
--theme-secondary: oklch(0.72 0.19 345);
--theme-secondary-light: oklch(0.82 0.14 345);
--theme-secondary-dark: oklch(0.62 0.24 340);
--theme-accent: oklch(0.88 0.18 95);
--theme-accent-light: oklch(0.92 0.16 95);
--theme-accent-dark: oklch(0.84 0.20 95);
--gradient-awesome: linear-gradient(135deg, oklch(0.62 0.28 310) 0%, oklch(0.54 0.26 295) 50%, oklch(0.88 0.18 95) 100%);
/* Awesome-specific (for compatibility) */
--awesome-purple: oklch(0.62 0.28 310);
--awesome-pink: oklch(0.72 0.19 345);
--awesome-gold: oklch(0.88 0.18 95);
}
.dark {
--background: oklch(0.15 0.01 286);
--foreground: oklch(0.98 0.005 286);
--card: oklch(0.18 0.01 286);
--card-foreground: oklch(0.98 0.005 286);
--popover: oklch(0.18 0.01 286);
--popover-foreground: oklch(0.98 0.005 286);
--primary: oklch(0.62 0.28 310);
--primary-foreground: oklch(0.15 0.01 286);
--secondary: oklch(0.72 0.19 345);
--secondary-foreground: oklch(0.98 0.005 286);
--muted: oklch(0.25 0.01 286);
--muted-foreground: oklch(0.7 0.02 286);
--accent: oklch(0.88 0.18 95);
--accent-foreground: oklch(0.98 0.005 286);
--destructive: oklch(0.5 0.22 25);
--destructive-foreground: oklch(0.98 0.005 286);
--border: oklch(0.3 0.01 286);
--input: oklch(0.25 0.01 286);
--ring: oklch(0.62 0.28 310);
--chart-1: oklch(0.55 0.24 290);
--chart-2: oklch(0.7 0.17 170);
--chart-3: oklch(0.8 0.18 80);
--chart-4: oklch(0.65 0.26 320);
--chart-5: oklch(0.67 0.25 25);
--sidebar: oklch(0.18 0.01 286);
--sidebar-foreground: oklch(0.98 0.005 286);
--sidebar-primary: oklch(0.55 0.24 290);
--sidebar-primary-foreground: oklch(0.98 0.005 286);
--sidebar-accent: oklch(0.25 0.01 286);
--sidebar-accent-foreground: oklch(0.98 0.005 286);
--sidebar-border: oklch(0.3 0.01 286);
--sidebar-ring: oklch(0.62 0.28 310);
}

240
app/imprint/page.tsx Normal file
View File

@@ -0,0 +1,240 @@
import Link from 'next/link'
import { ArrowLeft, Code, Heart, Github } from 'lucide-react'
import { Button } from '@/components/ui/button'
export const metadata = {
title: 'Imprint | Awesome',
description: 'Information about the Awesome project',
}
export default function ImprintPage() {
return (
<div className="min-h-screen bg-linear-to-br from-background via-background to-primary/5 px-6 py-12">
<div className="mx-auto max-w-4xl">
{/* Back Button */}
<Button asChild variant="ghost" className="mb-8">
<Link href="/">
<ArrowLeft className="mr-2 h-4 w-4" />
Back to Home
</Link>
</Button>
{/* Content */}
<article className="prose prose-lg dark:prose-invert max-w-none">
<h1 className="gradient-text">Imprint</h1>
<p className="lead">
Information about the Awesome web application and its development.
</p>
<h2>About This Project</h2>
<p>
Awesome is an independent, unofficial web application designed to provide a
beautiful and efficient way to explore curated awesome lists from GitHub. It
is built as a tribute to and extension of the amazing work done by the
open-source community.
</p>
<h2>Project Information</h2>
<h3>Purpose</h3>
<p>
This web application serves as a next-level, ground-breaking AAA interface
for discovering and exploring awesome lists. Our goals include:
</p>
<ul>
<li>Providing fast, full-text search across awesome lists</li>
<li>Offering an intuitive, beautiful user interface</li>
<li>Maintaining up-to-date content through automation</li>
<li>Making awesome lists accessible to everyone</li>
<li>Supporting the open-source community</li>
</ul>
<h3>Inspiration</h3>
<p>
This project is inspired by and builds upon the incredible{' '}
<a
href="https://github.com/sindresorhus/awesome"
target="_blank"
rel="noopener noreferrer"
className="gradient-text font-semibold"
>
sindresorhus/awesome
</a>{' '}
project and the entire awesome list ecosystem. We are grateful to all
contributors who maintain these valuable curated lists.
</p>
<h2>Technology Stack</h2>
<div className="not-prose my-8 grid gap-4 sm:grid-cols-2">
<div className="rounded-lg border-2 border-primary/20 bg-card p-6">
<div className="mb-2 flex items-center gap-2">
<Code className="h-5 w-5 text-primary" />
<h3 className="text-lg font-semibold">Frontend</h3>
</div>
<ul className="space-y-1 text-sm">
<li>Next.js 18</li>
<li>TypeScript</li>
<li>Tailwind CSS 4</li>
<li>shadcn/ui</li>
<li>Radix UI</li>
</ul>
</div>
<div className="rounded-lg border-2 border-secondary/20 bg-card p-6">
<div className="mb-2 flex items-center gap-2">
<Code className="h-5 w-5 text-secondary" />
<h3 className="text-lg font-semibold">Backend</h3>
</div>
<ul className="space-y-1 text-sm">
<li>Node.js 22+</li>
<li>SQLite3</li>
<li>FTS5 Search</li>
<li>GitHub API</li>
<li>GitHub Actions</li>
</ul>
</div>
</div>
<h2>Features</h2>
<ul>
<li>
<strong>Lightning-Fast Search:</strong> Powered by SQLite FTS5 for instant
full-text search
</li>
<li>
<strong>Always Fresh:</strong> Automated database updates every 6 hours via
GitHub Actions
</li>
<li>
<strong>Smart Updates:</strong> Service worker-based background updates with
user notifications
</li>
<li>
<strong>Beautiful UI:</strong> Carefully crafted design with purple/pink/gold
theme
</li>
<li>
<strong>PWA Ready:</strong> Install as an app on any device with offline
support
</li>
<li>
<strong>Keyboard Shortcuts:</strong> Efficient navigation with K / Ctrl+K
command palette
</li>
<li>
<strong>Dark Mode:</strong> Automatic theme switching based on system
preferences
</li>
<li>
<strong>Responsive:</strong> Works perfectly on desktop, tablet, and mobile
</li>
</ul>
<h2>Data Sources</h2>
<p>
All content displayed on Awesome is sourced from public GitHub repositories.
We use the GitHub API to fetch and aggregate information about awesome
lists. The data includes:
</p>
<ul>
<li>Repository metadata (name, description, stars, etc.)</li>
<li>README content</li>
<li>Topics and categories</li>
<li>Last update timestamps</li>
</ul>
<h2>Attribution</h2>
<h3>Original Awesome Project</h3>
<p>
The awesome list concept and curation standards are maintained by{' '}
<a
href="https://github.com/sindresorhus"
target="_blank"
rel="noopener noreferrer"
>
Sindre Sorhus
</a>{' '}
and the amazing community of contributors.
</p>
<h3>Color Scheme</h3>
<p>
Our beautiful purple/pink/gold theme is inspired by and matches the colors
used in the awesome CLI application, maintaining visual consistency across
the awesome ecosystem.
</p>
<h3>Open Source Community</h3>
<p>
This project wouldn&apos;t be possible without the countless developers who
contribute to open-source projects and maintain awesome lists. Thank you! 💜
</p>
<h2>Development</h2>
<p className="flex items-center gap-2">
<span>Built with</span>
<Heart className="inline h-5 w-5 fill-secondary text-secondary" />
<span>and maximum awesomeness by the community</span>
</p>
<div className="not-prose my-6">
<Button asChild className="btn-awesome gap-2">
<a
href="https://github.com/sindresorhus/awesome"
target="_blank"
rel="noopener noreferrer"
>
<Github className="h-5 w-5" />
<span>View Original Awesome Project</span>
</a>
</Button>
</div>
<h2>License</h2>
<p>
This web application is provided as-is for the benefit of the community. All
displayed content retains its original licensing from the source
repositories.
</p>
<h2>Contact & Contributions</h2>
<p>
We welcome feedback, bug reports, and contributions! If you&apos;d like to get
involved:
</p>
<ul>
<li>Report issues on our GitHub repository</li>
<li>Suggest new features or improvements</li>
<li>Contribute to the codebase</li>
<li>Share the project with others</li>
</ul>
<hr />
<p className="text-center text-muted-foreground">
<span className="gradient-text text-xl font-bold">Stay Awesome!</span>
<br />
💜💗💛
</p>
<p className="text-sm text-muted-foreground">
Last updated: {new Date().toLocaleDateString('en-US', {
year: 'numeric',
month: 'long',
day: 'numeric',
})}
</p>
</article>
</div>
</div>
)
}

87
app/layout.tsx Normal file
View File

@@ -0,0 +1,87 @@
import type { Metadata, Viewport } from 'next'
import { Inter } from 'next/font/google'
import './globals.css'
import 'highlight.js/styles/github-dark.css'
import { Toaster } from '@/components/ui/sonner'
import { WorkerProvider } from '@/components/providers/worker-provider'
import { CommandProvider } from '@/components/providers/command-provider'
import { AppHeader } from '@/components/layout/app-header'
import { ThemeProvider } from 'next-themes'
const inter = Inter({ subsets: ['latin'] })
export const viewport: Viewport = {
themeColor: '#DA22FF',
}
export const metadata: Metadata = {
title: 'Awesome - Curated Lists Explorer',
description: 'Next-level ground-breaking AAA webapp for exploring awesome lists from GitHub',
manifest: '/manifest.json',
appleWebApp: {
capable: true,
statusBarStyle: 'default',
title: 'Awesome',
},
formatDetection: {
telephone: false,
},
icons: {
icon: [
{ url: '/favicon.svg', type: 'image/svg+xml' },
{ url: '/icon.svg', type: 'image/svg+xml', sizes: 'any' }
],
apple: '/apple-touch-icon.svg',
shortcut: '/favicon.svg',
},
openGraph: {
type: 'website',
locale: 'en_US',
url: 'https://awesome.example.com',
siteName: 'Awesome',
title: 'Awesome - Curated Lists Explorer',
description: 'Explore and discover curated awesome lists from GitHub',
images: [
{
url: '/og-image.png',
width: 1200,
height: 630,
alt: 'Awesome',
},
],
},
twitter: {
card: 'summary_large_image',
title: 'Awesome - Curated Lists Explorer',
description: 'Explore and discover curated awesome lists from GitHub',
images: ['/og-image.png'],
},
}
export default function RootLayout({
children,
}: {
children: React.ReactNode
}) {
return (
<html lang="en" suppressHydrationWarning>
<head />
<body className={inter.className} suppressHydrationWarning>
<ThemeProvider
attribute="class"
defaultTheme="system"
enableSystem
disableTransitionOnChange
>
<WorkerProvider>
<CommandProvider>
<AppHeader />
{children}
</CommandProvider>
</WorkerProvider>
<Toaster />
</ThemeProvider>
</body>
</html>
)
}

183
app/legal/page.tsx Normal file
View File

@@ -0,0 +1,183 @@
import Link from 'next/link'
import { ArrowLeft } from 'lucide-react'
import { Button } from '@/components/ui/button'
export const metadata = {
title: 'Legal | Awesome',
description: 'Legal information and terms of use',
}
export default function LegalPage() {
return (
<div className="min-h-screen bg-linear-to-br from-background via-background to-primary/5 px-6 py-12">
<div className="mx-auto max-w-4xl">
{/* Back Button */}
<Button asChild variant="ghost" className="mb-8">
<Link href="/">
<ArrowLeft className="mr-2 h-4 w-4" />
Back to Home
</Link>
</Button>
{/* Content */}
<article className="prose prose-lg dark:prose-invert max-w-none">
<h1 className="gradient-text">Legal Information</h1>
<p className="lead">
Welcome to Awesome. This page outlines the legal terms and conditions for
using our service.
</p>
<h2>Terms of Use</h2>
<h3>Acceptance of Terms</h3>
<p>
By accessing and using Awesome, you accept and agree to be bound by the
terms and provision of this agreement. If you do not agree to these terms,
please do not use our service.
</p>
<h3>Use License</h3>
<p>
Permission is granted to temporarily access the materials (information or
software) on Awesome for personal, non-commercial transitory viewing only.
This is the grant of a license, not a transfer of title, and under this
license you may not:
</p>
<ul>
<li>Modify or copy the materials</li>
<li>Use the materials for any commercial purpose</li>
<li>
Attempt to decompile or reverse engineer any software contained on Awesome
</li>
<li>
Remove any copyright or other proprietary notations from the materials
</li>
<li>
Transfer the materials to another person or &quot;mirror&quot; the materials on
any other server
</li>
</ul>
<h2>Content and Attribution</h2>
<h3>Third-Party Content</h3>
<p>
Awesome aggregates and displays content from GitHub repositories, primarily
from the{' '}
<a
href="https://github.com/sindresorhus/awesome"
target="_blank"
rel="noopener noreferrer"
>
Awesome
</a>{' '}
project and related lists. We do not claim ownership of any third-party
content displayed on this site.
</p>
<h3>GitHub API</h3>
<p>
This service uses the GitHub API to fetch repository information. All data
is subject to GitHub&apos;s terms of service and API usage policies.
</p>
<h3>Attribution</h3>
<p>
All content maintains attribution to the original authors and repositories.
Links to original sources are provided throughout the application.
</p>
<h2>Data Collection and Privacy</h2>
<h3>Personal Data</h3>
<p>
Awesome does not collect, store, or process personal data. We do not use
cookies for tracking purposes. The service operates entirely client-side
with data fetched from our public database.
</p>
<h3>Service Workers</h3>
<p>
We use service workers to enable offline functionality and improve
performance. These are stored locally in your browser and can be cleared at
any time.
</p>
<h2>Intellectual Property</h2>
<h3>Awesome Branding</h3>
<p>
The &quot;Awesome&quot; name and logo are derived from the original{' '}
<a
href="https://github.com/sindresorhus/awesome"
target="_blank"
rel="noopener noreferrer"
>
sindresorhus/awesome
</a>{' '}
project. This web application is an independent, unofficial viewer for
awesome lists.
</p>
<h3>Open Source</h3>
<p>
This project respects and builds upon the open-source community. All
displayed content is from public repositories and is subject to their
respective licenses.
</p>
<h2>Disclaimers</h2>
<h3>No Warranty</h3>
<p>
The materials on Awesome are provided on an &apos;as is&apos; basis. Awesome makes no
warranties, expressed or implied, and hereby disclaims and negates all other
warranties including, without limitation, implied warranties or conditions
of merchantability, fitness for a particular purpose, or non-infringement of
intellectual property or other violation of rights.
</p>
<h3>Limitations</h3>
<p>
In no event shall Awesome or its suppliers be liable for any damages
(including, without limitation, damages for loss of data or profit, or due
to business interruption) arising out of the use or inability to use the
materials on Awesome.
</p>
<h2>Links to Third-Party Sites</h2>
<p>
Awesome contains links to third-party websites. These links are provided for
your convenience. We have no control over the content of those sites and
accept no responsibility for them or for any loss or damage that may arise
from your use of them.
</p>
<h2>Modifications</h2>
<p>
Awesome may revise these terms of service at any time without notice. By
using this website, you are agreeing to be bound by the then current version
of these terms of service.
</p>
<h2>Contact</h2>
<p>
If you have any questions about these legal terms, please open an issue on
our GitHub repository.
</p>
<hr />
<p className="text-sm text-muted-foreground">
Last updated: {new Date().toLocaleDateString('en-US', {
year: 'numeric',
month: 'long',
day: 'numeric',
})}
</p>
</article>
</div>
</div>
)
}

273
app/list/[id]/page.tsx Normal file
View File

@@ -0,0 +1,273 @@
'use client'
import * as React from 'react'
import Link from 'next/link'
import { ArrowLeft, ExternalLink, GitFork, Code, List, Star, FileText } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import { Skeleton } from '@/components/ui/skeleton'
import { useParams } from 'next/navigation'
import { SlidingPanel, SlidingPanelMain, SlidingPanelSide } from '@/components/personal-list/sliding-panel'
import { PersonalListEditor } from '@/components/personal-list/personal-list-editor'
import { PushToListButton } from '@/components/personal-list/push-to-list-button'
import { usePersonalListStore } from '@/lib/personal-list-store'
interface Repository {
id: number
name: string
url: string
description: string | null
stars: number | null
forks: number | null
language: string | null
topics: string | null
}
interface AwesomeList {
id: number
name: string
url: string
description: string | null
category: string | null
stars: number | null
}
interface ListDetailResponse {
list: AwesomeList
repositories: {
results: Repository[]
total: number
page: number
pageSize: number
totalPages: number
}
}
export default function ListDetailPage() {
const params = useParams()
const listId = params.id as string
const [data, setData] = React.useState<ListDetailResponse | null>(null)
const [loading, setLoading] = React.useState(true)
const [page, setPage] = React.useState(1)
const { isEditorOpen, closeEditor, openEditor } = usePersonalListStore()
React.useEffect(() => {
setLoading(true)
fetch(`/api/lists/${listId}?page=${page}`)
.then(res => res.json())
.then(data => {
setData(data)
setLoading(false)
})
.catch(err => {
console.error('Failed to fetch list:', err)
setLoading(false)
})
}, [listId, page])
if (loading && !data) {
return (
<div className="min-h-screen bg-linear-to-br from-background via-background to-primary/5 px-6 py-12">
<div className="mx-auto max-w-7xl">
<Skeleton className="mb-8 h-10 w-32" />
<Skeleton className="mb-4 h-12 w-2/3" />
<Skeleton className="mb-8 h-6 w-full" />
<div className="space-y-4">
{[...Array(10)].map((_, i) => (
<Skeleton key={i} className="h-32" />
))}
</div>
</div>
</div>
)
}
if (!data) {
return (
<div className="min-h-screen bg-linear-to-br from-background via-background to-primary/5 px-6 py-12">
<div className="mx-auto max-w-7xl text-center">
<h1 className="mb-4 text-3xl font-bold">List Not Found</h1>
<p className="mb-8 text-muted-foreground">
The awesome list you&apos;re looking for doesn&apos;t exist.
</p>
<Button asChild>
<Link href="/browse">
<ArrowLeft className="mr-2 h-4 w-4" />
Back to Browse
</Link>
</Button>
</div>
</div>
)
}
const { list, repositories } = data
return (
<div className="min-h-screen">
<SlidingPanel isOpen={isEditorOpen} onClose={closeEditor}>
<SlidingPanelMain>
<div className="min-h-screen bg-linear-to-br from-background via-background to-primary/5">
{/* Header */}
<div className="border-b bg-background/80 backdrop-blur-sm">
<div className="mx-auto max-w-7xl px-6 py-6">
<div className="mb-4 flex items-center justify-between">
<Button asChild variant="ghost">
<Link href="/browse">
<ArrowLeft className="mr-2 h-4 w-4" />
Back to Browse
</Link>
</Button>
<Button
variant="outline"
size="sm"
onClick={openEditor}
className="gap-2"
>
<List className="h-4 w-4" />
My Awesome List
</Button>
</div>
<h1 className="gradient-text mb-3 text-4xl font-bold">{list.name}</h1>
{list.description && (
<p className="mb-4 text-lg text-muted-foreground">{list.description}</p>
)}
<div className="flex flex-wrap items-center gap-4">
{list.category && (
<Badge variant="secondary" className="text-sm">
{list.category}
</Badge>
)}
<Button asChild variant="outline" size="sm">
<a href={list.url} target="_blank" rel="noopener noreferrer">
<ExternalLink className="mr-2 h-4 w-4" />
View on GitHub
</a>
</Button>
</div>
<div className="mt-4 text-sm text-muted-foreground">
Showing {repositories.results.length} of {repositories.total.toLocaleString()} repositories
</div>
</div>
</div>
{/* Repositories */}
<div className="mx-auto max-w-7xl px-6 py-8">
<div className="space-y-4">
{repositories.results.map((repo) => {
const topics = repo.topics ? repo.topics.split(',') : []
return (
<div key={repo.id} className="card-awesome rounded-lg bg-card p-6">
<div className="mb-2 flex items-start justify-between gap-4">
<h3 className="flex-1 text-xl font-semibold">
<Link
href={`/repository/${repo.id}`}
className="group inline-flex items-center gap-2 text-primary hover:text-primary/80"
>
{repo.name}
<FileText className="h-4 w-4 opacity-0 transition-opacity group-hover:opacity-100" />
</Link>
</h3>
<div className="flex items-center gap-2">
<div className="flex items-center gap-3 text-sm text-muted-foreground">
{repo.stars !== null && (
<div className="flex items-center gap-1">
<Star className="h-4 w-4 fill-current text-accent" />
<span>{repo.stars.toLocaleString()}</span>
</div>
)}
{repo.forks !== null && (
<div className="flex items-center gap-1">
<GitFork className="h-4 w-4" />
<span>{repo.forks.toLocaleString()}</span>
</div>
)}
</div>
<PushToListButton
title={repo.name}
description={repo.description || 'No description available'}
url={repo.url}
repository={repo.name}
variant="outline"
size="sm"
showLabel={false}
/>
</div>
</div>
{repo.description && (
<p className="mb-3 text-muted-foreground">{repo.description}</p>
)}
<div className="flex flex-wrap items-center gap-2">
{repo.language && (
<Badge variant="secondary">
<Code className="mr-1 h-3 w-3" />
{repo.language}
</Badge>
)}
{topics.slice(0, 5).map((topic) => (
<Badge key={topic} variant="outline">
{topic.trim()}
</Badge>
))}
{topics.length > 5 && (
<Badge variant="outline">
+{topics.length - 5} more
</Badge>
)}
<Link href={`/repository/${repo.id}`} className="ml-auto">
<Button variant="ghost" size="sm" className="gap-2">
<FileText className="h-4 w-4" />
<span>View README</span>
</Button>
</Link>
</div>
</div>
)
})}
</div>
{/* Pagination */}
{repositories.totalPages > 1 && (
<div className="mt-8 flex items-center justify-center gap-2">
<Button
variant="outline"
disabled={repositories.page === 1}
onClick={() => setPage(repositories.page - 1)}
>
Previous
</Button>
<span className="mx-4 text-sm text-muted-foreground">
Page {repositories.page} of {repositories.totalPages}
</span>
<Button
variant="outline"
disabled={repositories.page === repositories.totalPages}
onClick={() => setPage(repositories.page + 1)}
>
Next
</Button>
</div>
)}
</div>
</div>
</SlidingPanelMain>
<SlidingPanelSide title="My Awesome List">
<PersonalListEditor />
</SlidingPanelSide>
</SlidingPanel>
</div>
)
}

72
app/my-list/page.tsx Normal file
View File

@@ -0,0 +1,72 @@
'use client'
import * as React from 'react'
import Link from 'next/link'
import { ArrowLeft, Download, FileText } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { PersonalListEditor } from '@/components/personal-list/personal-list-editor'
import { usePersonalListStore } from '@/lib/personal-list-store'
import { AwesomeIcon } from '@/components/ui/awesome-icon'
export default function MyListPage() {
const { items, generateMarkdown } = usePersonalListStore()
const handleExportMarkdown = () => {
const md = generateMarkdown()
const blob = new Blob([md], { type: 'text/markdown' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = `my-awesome-list-${Date.now()}.md`
document.body.appendChild(a)
a.click()
document.body.removeChild(a)
URL.revokeObjectURL(url)
}
return (
<div className="min-h-screen bg-linear-to-br from-background via-background to-primary/5">
{/* Header */}
<div className="border-b bg-background/80 backdrop-blur-sm">
<div className="mx-auto max-w-7xl px-6 py-6">
<div className="mb-4 flex items-center justify-between">
<Button asChild variant="ghost">
<Link href="/">
<ArrowLeft className="mr-2 h-4 w-4" />
Back to Home
</Link>
</Button>
{items.length > 0 && (
<Button onClick={handleExportMarkdown} variant="outline" size="sm" className="gap-2">
<FileText className="h-4 w-4" />
Export Markdown
</Button>
)}
</div>
<div className="flex items-center gap-3">
<div className="flex h-12 w-12 items-center justify-center rounded-xl bg-gradient-awesome p-2 shadow-lg">
<AwesomeIcon size={32} className="[&_path]:fill-white [&_circle]:fill-white/80" />
</div>
<div>
<h1 className="gradient-text text-4xl font-bold">My Awesome List</h1>
<p className="text-muted-foreground">
{items.length === 0
? 'Start building your personal collection'
: `${items.length} ${items.length === 1 ? 'item' : 'items'} in your collection`}
</p>
</div>
</div>
</div>
</div>
{/* Editor */}
<div className="mx-auto h-[calc(100vh-180px)] max-w-7xl px-6 py-8">
<div className="h-full overflow-hidden rounded-lg border border-border bg-card shadow-xl">
<PersonalListEditor />
</div>
</div>
</div>
)
}

185
app/not-found.tsx Normal file
View File

@@ -0,0 +1,185 @@
'use client'
import * as React from 'react'
import Link from 'next/link'
import { Home, Search, Sparkles } from 'lucide-react'
import { Button } from '@/components/ui/button'
export default function NotFound() {
const [easterEggFound, setEasterEggFound] = React.useState(false)
const [clicks, setClicks] = React.useState(0)
const [showSecret, setShowSecret] = React.useState(false)
const handleLogoClick = () => {
setClicks((prev) => prev + 1)
if (clicks + 1 === 5) {
setEasterEggFound(true)
setTimeout(() => setShowSecret(true), 500)
}
}
return (
<div className="relative flex min-h-screen flex-col items-center justify-center overflow-hidden bg-linear-to-br from-background via-background to-primary/5 px-6">
{/* Background Animation */}
<div className="absolute inset-0 -z-10 overflow-hidden">
<div
className={`absolute left-[20%] top-[10%] h-[500px] w-[500px] rounded-full bg-primary/20 blur-[128px] transition-all duration-1000 ${
easterEggFound ? 'animate-pulse' : ''
}`}
/>
<div
className={`absolute right-[20%] top-[50%] h-[400px] w-[400px] rounded-full bg-secondary/20 blur-[128px] transition-all duration-1000 ${
easterEggFound ? 'animate-pulse' : ''
}`}
/>
<div
className={`absolute bottom-[10%] left-[50%] h-[300px] w-[300px] rounded-full bg-accent/20 blur-[128px] transition-all duration-1000 ${
easterEggFound ? 'animate-pulse' : ''
}`}
/>
</div>
<div className="mx-auto max-w-2xl text-center">
{/* 404 Number */}
<div
className={`mb-8 cursor-pointer select-none transition-all duration-500 ${
easterEggFound ? 'scale-110' : 'hover:scale-105'
}`}
onClick={handleLogoClick}
>
<h1
className={`text-[12rem] font-black leading-none transition-all duration-500 sm:text-[16rem] ${
easterEggFound
? 'gradient-text animate-[shimmer_2s_linear_infinite]'
: 'text-primary/20'
}`}
>
404
</h1>
</div>
{/* Main Message */}
{!easterEggFound ? (
<>
<h2 className="mb-4 text-3xl font-bold sm:text-4xl">
Oops! Page Not Found
</h2>
<p className="mb-8 text-lg text-muted-foreground">
Looks like you&apos;ve ventured into uncharted territory. This page
doesn&apos;t exist... yet!
</p>
</>
) : (
<>
<h2 className="gradient-text mb-4 animate-[slideInFromTop_0.5s_ease-out] text-4xl font-bold sm:text-5xl">
🎉 You Found It! 🎉
</h2>
<p className="mb-8 animate-[slideInFromTop_0.7s_ease-out] text-xl text-muted-foreground">
Congratulations! You discovered the{' '}
<span className="gradient-text-pink font-bold">AWESOME</span> easter
egg!
</p>
</>
)}
{/* Easter Egg Secret Message */}
{showSecret && (
<div className="mb-8 animate-[slideInFromTop_1s_ease-out] rounded-2xl border-2 border-primary/40 bg-linear-to-br from-primary/20 via-secondary/20 to-accent/20 p-6 backdrop-blur-sm">
<div className="mb-4 flex items-center justify-center gap-2">
<Sparkles className="h-6 w-6 animate-pulse text-accent" />
<h3 className="gradient-text-gold text-2xl font-bold">
Secret Message
</h3>
<Sparkles className="h-6 w-6 animate-pulse text-accent" />
</div>
<p className="text-lg leading-relaxed">
<span className="gradient-text font-semibold">AWESOME</span> isn&apos;t
just a word, it&apos;s a way of life! Keep exploring, keep learning,
and stay awesome! 💜💗💛
</p>
<div className="mt-4 text-sm text-muted-foreground">
Pro tip: You can press{' '}
<kbd className="mx-1">K</kbd> or <kbd>Ctrl+K</kbd> to search from
anywhere!
</div>
</div>
)}
{/* Action Buttons */}
<div className="flex flex-col items-center justify-center gap-4 sm:flex-row">
<Button asChild size="lg" className="btn-awesome group gap-2">
<Link href="/">
<Home className="h-5 w-5" />
<span>Back to Home</span>
</Link>
</Button>
<Button asChild variant="outline" size="lg" className="gap-2">
<Link href="/search">
<Search className="h-5 w-5" />
<span>Search Instead</span>
</Link>
</Button>
</div>
{/* Hint for Easter Egg */}
{!easterEggFound && (
<p className="mt-12 animate-pulse text-sm text-muted-foreground/50">
Psst... try clicking the 404 😉
</p>
)}
{/* Success Confetti Effect */}
{easterEggFound && (
<div className="pointer-events-none fixed inset-0 flex items-center justify-center">
{[...Array(50)].map((_, i) => (
<div
key={i}
className="absolute h-2 w-2 animate-[confetti_3s_ease-out_forwards] rounded-full"
style={{
left: `${50 + Math.random() * 10 - 5}%`,
top: `${30 + Math.random() * 10 - 5}%`,
backgroundColor: [
'#DA22FF',
'#FF69B4',
'#FFD700',
'#9733EE',
'#FF1493',
][Math.floor(Math.random() * 5)],
animationDelay: `${Math.random() * 0.5}s`,
transform: `rotate(${Math.random() * 360}deg)`,
}}
/>
))}
</div>
)}
</div>
{/* Inline Animations */}
<style jsx>{`
@keyframes confetti {
0% {
transform: translateY(0) rotate(0deg);
opacity: 1;
}
100% {
transform: translateY(100vh) rotate(720deg);
opacity: 0;
}
}
@keyframes slideInFromTop {
0% {
opacity: 0;
transform: translateY(-20px);
}
100% {
opacity: 1;
transform: translateY(0);
}
}
`}</style>
</div>
)
}

255
app/page.tsx Normal file
View File

@@ -0,0 +1,255 @@
import Link from 'next/link'
import { ArrowRight, Search, Star, Sparkles, Zap, Shield, Heart } from 'lucide-react'
import { getStats } from '@/lib/db'
import { AwesomeIcon } from '@/components/ui/awesome-icon'
export default function Home() {
const stats = getStats()
return (
<div className="min-h-screen bg-linear-to-br from-background via-background to-primary/5">
{/* Hero Section */}
<section className="relative overflow-hidden px-6 py-24 sm:py-32 lg:px-8">
{/* Background Gradient Orbs */}
<div className="absolute inset-0 -z-10 overflow-hidden">
<div className="absolute left-[20%] top-0 h-[500px] w-[500px] rounded-full bg-primary/20 blur-[128px]" />
<div className="absolute right-[20%] top-[40%] h-[400px] w-[400px] rounded-full bg-secondary/20 blur-[128px]" />
<div className="absolute bottom-0 left-[50%] h-[300px] w-[300px] rounded-full bg-accent/20 blur-[128px]" />
</div>
<div className="mx-auto max-w-4xl text-center">
{/* Badge */}
<div className="mb-8 inline-flex items-center gap-2 rounded-full border border-primary/20 bg-primary/10 px-4 py-2 text-sm font-medium text-primary backdrop-blur-sm">
<AwesomeIcon size={16} />
<span>Explore {stats.totalLists}+ Curated Lists</span>
</div>
{/* Main Heading */}
<h1 className="mb-6 text-5xl font-bold tracking-tight sm:text-7xl">
<span className="gradient-text">Awesome</span>
<br />
<span className="text-foreground">Discovery Made Simple</span>
</h1>
{/* Subheading */}
<p className="mx-auto mb-12 max-w-2xl text-lg leading-relaxed text-muted-foreground sm:text-xl">
Your gateway to the world&apos;s most{' '}
<span className="gradient-text-pink font-semibold">curated collections</span>.
Lightning-fast search, beautiful interface, and always up-to-date.
</p>
{/* CTA Buttons */}
<div className="flex flex-col items-center justify-center gap-4 sm:flex-row">
<Link
href="/search"
className="btn-awesome group inline-flex items-center gap-2 px-8 py-4 text-lg"
>
<Search className="h-5 w-5" />
<span>Start Exploring</span>
<ArrowRight className="h-5 w-5 transition-transform group-hover:translate-x-1" />
</Link>
<Link
href="/browse"
className="inline-flex items-center gap-2 rounded-lg border-2 border-primary/20 bg-background/80 px-8 py-4 text-lg font-semibold text-foreground backdrop-blur-sm transition-all hover:border-primary/40 hover:bg-primary/5"
>
<Star className="h-5 w-5" />
<span>Browse Collections</span>
</Link>
</div>
{/* Quick Search Hint */}
<p className="mt-8 text-sm text-muted-foreground">
Pro tip: Press{' '}
<kbd className="mx-1"></kbd>
<kbd>K</kbd>
{' '}or{' '}
<kbd className="mx-1">Ctrl</kbd>
<kbd>K</kbd>
{' '}to search from anywhere
</p>
</div>
</section>
{/* Features Section */}
<section className="px-6 py-24 lg:px-8">
<div className="mx-auto max-w-7xl">
<div className="mb-16 text-center">
<h2 className="mb-4 text-3xl font-bold sm:text-4xl">
Why <span className="gradient-text">Awesome</span>?
</h2>
<p className="mx-auto max-w-2xl text-lg text-muted-foreground">
Built with cutting-edge technology to deliver the best experience
</p>
</div>
{/* SVG Gradient Definitions */}
<svg width="0" height="0" className="absolute">
<defs>
<linearGradient id="gradient-primary" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" style={{ stopColor: 'var(--color-primary)', stopOpacity: 1 }} />
<stop offset="100%" style={{ stopColor: 'var(--color-primary-dark)', stopOpacity: 1 }} />
</linearGradient>
<linearGradient id="gradient-secondary" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" style={{ stopColor: 'var(--color-secondary)', stopOpacity: 1 }} />
<stop offset="100%" style={{ stopColor: 'var(--color-secondary-dark)', stopOpacity: 1 }} />
</linearGradient>
<linearGradient id="gradient-accent" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" style={{ stopColor: 'var(--color-accent)', stopOpacity: 1 }} />
<stop offset="100%" style={{ stopColor: 'var(--color-accent-dark)', stopOpacity: 1 }} />
</linearGradient>
<linearGradient id="gradient-awesome" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" style={{ stopColor: 'var(--color-primary)', stopOpacity: 1 }} />
<stop offset="50%" style={{ stopColor: 'var(--color-secondary)', stopOpacity: 1 }} />
<stop offset="100%" style={{ stopColor: 'var(--color-accent)', stopOpacity: 1 }} />
</linearGradient>
</defs>
</svg>
<div className="grid gap-8 md:grid-cols-2 lg:grid-cols-3">
{/* Feature 1 */}
<div className="card-awesome group rounded-xl bg-card p-8">
<div className="mb-4 inline-flex h-12 w-12 items-center justify-center rounded-lg bg-linear-to-br from-primary/10 via-secondary/10 to-accent/10 shadow-lg ring-1 ring-primary/10 transition-all group-hover:ring-primary/30 group-hover:shadow-xl group-hover:shadow-primary/20">
<Zap className="h-6 w-6 icon-gradient-awesome" strokeWidth={2} />
</div>
<h3 className="mb-3 text-xl font-semibold">Lightning Fast</h3>
<p className="text-muted-foreground">
Powered by SQLite FTS5 for instant full-text search across thousands of repositories
</p>
</div>
{/* Feature 2 */}
<div className="card-awesome group rounded-xl bg-card p-8">
<div className="mb-4 inline-flex h-12 w-12 items-center justify-center rounded-lg bg-linear-to-br from-primary/10 via-secondary/10 to-accent/10 shadow-lg ring-1 ring-primary/10 transition-all group-hover:ring-primary/30 group-hover:shadow-xl group-hover:shadow-primary/20">
<Sparkles className="h-6 w-6 icon-gradient-awesome" strokeWidth={2} />
</div>
<h3 className="mb-3 text-xl font-semibold">Always Fresh</h3>
<p className="text-muted-foreground">
Automated updates every 6 hours via GitHub Actions. Never miss a new awesome list
</p>
</div>
{/* Feature 3 */}
<div className="card-awesome group rounded-xl bg-card p-8">
<div className="mb-4 inline-flex h-12 w-12 items-center justify-center rounded-lg bg-linear-to-br from-primary/10 via-secondary/10 to-accent/10 shadow-lg ring-1 ring-primary/10 transition-all group-hover:ring-primary/30 group-hover:shadow-xl group-hover:shadow-primary/20">
<Search className="h-6 w-6 icon-gradient-awesome" strokeWidth={2} />
</div>
<h3 className="mb-3 text-xl font-semibold">Smart Search</h3>
<p className="text-muted-foreground">
Faceted filtering by language, stars, topics. Find exactly what you need
</p>
</div>
{/* Feature 4 */}
<div className="card-awesome group rounded-xl bg-card p-8">
<div className="mb-4 inline-flex h-12 w-12 items-center justify-center rounded-lg bg-linear-to-br from-primary/10 via-secondary/10 to-accent/10 shadow-lg ring-1 ring-primary/10 transition-all group-hover:ring-primary/30 group-hover:shadow-xl group-hover:shadow-primary/20">
<Heart className="h-6 w-6 icon-gradient-awesome" strokeWidth={2} />
</div>
<h3 className="mb-3 text-xl font-semibold">Beautiful UI</h3>
<p className="text-muted-foreground">
Stunning purple/pink/gold theme with smooth animations and responsive design
</p>
</div>
{/* Feature 5 */}
<div className="card-awesome group rounded-xl bg-card p-8">
<div className="mb-4 inline-flex h-12 w-12 items-center justify-center rounded-lg bg-linear-to-br from-primary/10 via-secondary/10 to-accent/10 shadow-lg ring-1 ring-primary/10 transition-all group-hover:ring-primary/30 group-hover:shadow-xl group-hover:shadow-primary/20">
<Shield className="h-6 w-6 icon-gradient-awesome" strokeWidth={2} />
</div>
<h3 className="mb-3 text-xl font-semibold">PWA Ready</h3>
<p className="text-muted-foreground">
Install as an app on any device. Works offline with service worker support
</p>
</div>
{/* Feature 6 */}
<div className="card-awesome group rounded-xl bg-card p-8">
<div className="mb-4 inline-flex h-12 w-12 items-center justify-center rounded-lg bg-linear-to-br from-primary/10 via-secondary/10 to-accent/10 shadow-lg ring-1 ring-primary/10 transition-all group-hover:ring-primary/30 group-hover:shadow-xl group-hover:shadow-primary/20">
<Star className="h-6 w-6 icon-gradient-awesome" strokeWidth={2} />
</div>
<h3 className="mb-3 text-xl font-semibold">Curated Quality</h3>
<p className="text-muted-foreground">
Only the best lists from the awesome ecosystem. Quality over quantity
</p>
</div>
</div>
</div>
</section>
{/* Stats Section */}
<section className="px-6 py-24 lg:px-8">
<div className="mx-auto max-w-7xl">
<div className="rounded-3xl border-2 border-primary/20 bg-linear-to-br from-primary/10 via-secondary/10 to-accent/10 p-12 backdrop-blur-sm">
<div className="grid gap-8 text-center md:grid-cols-3">
<div>
<div className="gradient-text mb-2 text-5xl font-bold">{stats.totalLists.toLocaleString()}</div>
<div className="text-lg text-muted-foreground">Curated Lists</div>
</div>
<div>
<div className="gradient-text-pink mb-2 text-5xl font-bold">{(stats.totalRepositories / 1000).toFixed(0)}K+</div>
<div className="text-lg text-muted-foreground">Repositories</div>
</div>
<div>
<div className="gradient-text-gold mb-2 text-5xl font-bold">6hr</div>
<div className="text-lg text-muted-foreground">Update Cycle</div>
</div>
</div>
</div>
</div>
</section>
{/* CTA Section */}
<section className="px-6 py-24 lg:px-8">
<div className="mx-auto max-w-4xl text-center">
<h2 className="mb-6 text-4xl font-bold">
Ready to discover{' '}
<span className="gradient-text">awesome</span> things?
</h2>
<p className="mb-12 text-lg text-muted-foreground">
Join thousands of developers exploring the best curated content
</p>
<Link
href="/search"
className="btn-awesome group inline-flex items-center gap-2 px-8 py-4 text-lg"
>
<Search className="h-5 w-5" />
<span>Start Your Journey</span>
<ArrowRight className="h-5 w-5 transition-transform group-hover:translate-x-1" />
</Link>
</div>
</section>
{/* Footer */}
<footer className="border-t border-border/40 px-6 py-12 lg:px-8">
<div className="mx-auto max-w-7xl">
<div className="flex flex-col items-center justify-between gap-4 sm:flex-row">
<div className="text-center sm:text-left">
<div className="gradient-text mb-2 text-xl font-bold">Awesome</div>
<p className="text-sm text-muted-foreground">
Built with 💜💗💛 and maximum awesomeness
</p>
</div>
<div className="flex flex-wrap justify-center gap-6 text-sm">
<Link href="/legal" className="text-muted-foreground hover:text-primary">
Legal
</Link>
<Link href="/disclaimer" className="text-muted-foreground hover:text-primary">
Disclaimer
</Link>
<Link href="/imprint" className="text-muted-foreground hover:text-primary">
Imprint
</Link>
<a
href="https://github.com/sindresorhus/awesome"
target="_blank"
rel="noopener noreferrer"
className="text-muted-foreground hover:text-primary"
>
GitHub
</a>
</div>
</div>
</div>
</footer>
</div>
)
}

View File

@@ -0,0 +1,98 @@
import { Suspense } from 'react'
import { notFound } from 'next/navigation'
import { Metadata } from 'next'
import { ReadmeViewer } from '@/components/readme/readme-viewer'
import { ReadmeHeader } from '@/components/readme/readme-header'
import { Skeleton } from '@/components/ui/skeleton'
import { getDb } from '@/lib/db'
interface PageProps {
params: {
owner: string
repo: string
}
}
async function getReadmeContent(owner: string, repo: string) {
try {
const db = getDb()
// Find repository by URL pattern
const repoUrl = `https://github.com/${owner}/${repo}`
const repository = db.prepare(`
SELECT r.*, rm.content, rm.raw_content
FROM repositories r
LEFT JOIN readmes rm ON r.id = rm.repository_id
WHERE r.url = ? OR r.url LIKE ?
LIMIT 1
`).get(repoUrl, `%${owner}/${repo}%`) as any
if (!repository || !repository.content) {
return null // Not found in database
}
// Return actual README content from database
return {
content: repository.content || repository.raw_content || '',
metadata: {
title: repository.name,
description: repository.description || `Repository: ${repository.name}`,
stars: repository.stars || 0,
lastUpdated: repository.last_commit || new Date().toISOString(),
url: repository.url,
},
}
} catch (error) {
console.error('Failed to fetch README:', error)
return null
}
}
export async function generateMetadata({ params }: PageProps): Promise<Metadata> {
const data = await getReadmeContent(params.owner, params.repo)
if (!data) {
return {
title: 'Not Found',
}
}
return {
title: `${data.metadata.title} | Awesome`,
description: data.metadata.description,
openGraph: {
title: data.metadata.title,
description: data.metadata.description,
type: 'article',
},
}
}
export default async function ReadmePage({ params }: PageProps) {
const data = await getReadmeContent(params.owner, params.repo)
if (!data) {
notFound()
}
return (
<div className="min-h-screen bg-background">
<ReadmeHeader metadata={data.metadata} />
<div className="mx-auto max-w-5xl px-6 py-12">
<Suspense
fallback={
<div className="space-y-4">
<Skeleton className="h-8 w-3/4" />
<Skeleton className="h-4 w-full" />
<Skeleton className="h-4 w-5/6" />
<Skeleton className="h-4 w-4/6" />
</div>
}
>
<ReadmeViewer content={data.content} />
</Suspense>
</div>
</div>
)
}

View File

@@ -0,0 +1,251 @@
'use client'
import * as React from 'react'
import Link from 'next/link'
import { useParams } from 'next/navigation'
import { ArrowLeft, ExternalLink, GitFork, Star, Code, AlertCircle } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import { Skeleton } from '@/components/ui/skeleton'
import { PushToListButton } from '@/components/personal-list/push-to-list-button'
import { marked } from 'marked'
import { markedHighlight } from 'marked-highlight'
import hljs from 'highlight.js'
interface Repository {
id: number
name: string
url: string
description: string | null
stars: number | null
forks: number | null
language: string | null
topics: string | null
}
interface Readme {
content: string
}
interface RepositoryDetailResponse {
id: number
name: string
url: string
description: string | null
stars: number | null
forks: number | null
language: string | null
topics: string | null
readme: Readme | null
}
// Configure marked with syntax highlighting and options
marked.use({
breaks: true,
gfm: true,
headerIds: true,
mangle: false,
})
marked.use(
markedHighlight({
langPrefix: 'hljs language-',
highlight(code, lang) {
const language = hljs.getLanguage(lang) ? lang : 'plaintext'
return hljs.highlight(code, { language }).value
}
})
)
export default function RepositoryDetailPage() {
const params = useParams()
const repositoryId = params.id as string
const [data, setData] = React.useState<RepositoryDetailResponse | null>(null)
const [loading, setLoading] = React.useState(true)
const [error, setError] = React.useState<string | null>(null)
React.useEffect(() => {
setLoading(true)
setError(null)
fetch(`/api/repositories/${repositoryId}`)
.then(async (res) => {
if (!res.ok) {
throw new Error('Failed to fetch repository')
}
return res.json()
})
.then((data) => {
setData(data)
setLoading(false)
})
.catch((err) => {
console.error('Failed to fetch repository:', err)
setError(err.message)
setLoading(false)
})
}, [repositoryId])
// Determine if content is already HTML or needs markdown parsing
// Must be called before any conditional returns
const readmeHtml = React.useMemo(() => {
if (!data?.readme?.content) return null
const content = data.readme.content
// Ensure content is a string before processing
if (typeof content !== 'string' || !content.trim()) return null
// Check if content is already HTML (starts with < tag)
const isHtml = content.trim().startsWith('<')
if (isHtml) {
// Content is already HTML, use as-is
return content
} else {
// Content is markdown, parse it
return marked.parse(content) as string
}
}, [data?.readme?.content])
const topics = data?.topics ? data.topics.split(',') : []
if (loading) {
return (
<div className="min-h-screen bg-linear-to-br from-background via-background to-primary/5 px-6 py-12">
<div className="mx-auto max-w-5xl">
<Skeleton className="mb-8 h-10 w-32" />
<Skeleton className="mb-4 h-12 w-2/3" />
<Skeleton className="mb-8 h-6 w-full" />
<div className="space-y-4">
<Skeleton className="h-96" />
</div>
</div>
</div>
)
}
if (error || !data) {
return (
<div className="min-h-screen bg-linear-to-br from-background via-background to-primary/5 px-6 py-12">
<div className="mx-auto max-w-5xl text-center">
<AlertCircle className="mx-auto mb-4 h-12 w-12 text-destructive" />
<h1 className="mb-4 text-3xl font-bold">Repository Not Found</h1>
<p className="mb-8 text-muted-foreground">
The repository you&apos;re looking for doesn&apos;t exist or couldn&apos;t be loaded.
</p>
<Button asChild>
<Link href="/browse">
<ArrowLeft className="mr-2 h-4 w-4" />
Back to Browse
</Link>
</Button>
</div>
</div>
)
}
return (
<div className="min-h-screen bg-linear-to-br from-background via-background to-primary/5">
{/* Header */}
<div className="border-b bg-background/80 backdrop-blur-sm">
<div className="mx-auto max-w-5xl px-6 py-6">
<div className="mb-4">
<Button asChild variant="ghost">
<Link href="/browse">
<ArrowLeft className="mr-2 h-4 w-4" />
Back to Browse
</Link>
</Button>
</div>
<div className="flex items-start justify-between gap-4">
<div className="flex-1">
<h1 className="gradient-text mb-3 text-4xl font-bold">{data.name}</h1>
{data.description && (
<p className="mb-4 text-lg text-muted-foreground">{data.description}</p>
)}
<div className="flex flex-wrap items-center gap-4">
{data.language && (
<Badge variant="secondary" className="text-sm">
<Code className="mr-1 h-3 w-3" />
{data.language}
</Badge>
)}
<div className="flex items-center gap-3 text-sm text-muted-foreground">
{data.stars !== null && (
<div className="flex items-center gap-1">
<Star className="h-4 w-4 fill-current text-accent" />
<span>{data.stars.toLocaleString()} stars</span>
</div>
)}
{data.forks !== null && (
<div className="flex items-center gap-1">
<GitFork className="h-4 w-4" />
<span>{data.forks.toLocaleString()} forks</span>
</div>
)}
</div>
</div>
{topics.length > 0 && (
<div className="mt-4 flex flex-wrap gap-2">
{topics.slice(0, 10).map((topic) => (
<Badge key={topic} variant="outline">
{topic.trim()}
</Badge>
))}
{topics.length > 10 && (
<Badge variant="outline">
+{topics.length - 10} more
</Badge>
)}
</div>
)}
</div>
<div className="flex flex-col gap-2">
<Button asChild variant="outline" size="sm">
<a href={data.url} target="_blank" rel="noopener noreferrer">
<ExternalLink className="mr-2 h-4 w-4" />
View on GitHub
</a>
</Button>
<PushToListButton
title={data.name}
description={data.description || 'No description available'}
url={data.url}
repository={data.name}
variant="outline"
size="sm"
showLabel={true}
/>
</div>
</div>
</div>
</div>
{/* README Content */}
<div className="mx-auto max-w-5xl px-6 py-8">
{readmeHtml ? (
<article
className="prose prose-lg prose-slate dark:prose-invert max-w-none rounded-xl border border-border bg-card p-8 shadow-sm"
dangerouslySetInnerHTML={{ __html: readmeHtml }}
/>
) : (
<div className="rounded-xl border border-dashed border-border bg-muted/30 p-12 text-center">
<AlertCircle className="mx-auto mb-4 h-8 w-8 text-muted-foreground" />
<h3 className="mb-2 text-lg font-semibold">No README Available</h3>
<p className="text-muted-foreground">
This repository doesn&apos;t have a README file or it couldn&apos;t be loaded.
</p>
</div>
)}
</div>
</div>
)
}

372
app/search/page.tsx Normal file
View File

@@ -0,0 +1,372 @@
'use client'
import * as React from 'react'
import { useSearchParams, useRouter } from 'next/navigation'
import { Search, Star, Filter, SlidersHorizontal, ExternalLink } from 'lucide-react'
import { Input } from '@/components/ui/input'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import {
Sheet,
SheetContent,
SheetDescription,
SheetHeader,
SheetTitle,
SheetTrigger,
} from '@/components/ui/sheet'
import { Skeleton } from '@/components/ui/skeleton'
import { ScrollArea } from '@/components/ui/scroll-area'
interface SearchResult {
repository_id: number
repository_name: string
repository_url: string
description: string | null
stars: number | null
language: string | null
topics: string | null
awesome_list_name: string | null
awesome_list_category: string | null
snippet: string | null
}
interface SearchResponse {
results: SearchResult[]
total: number
page: number
pageSize: number
totalPages: number
}
interface StatsResponse {
languages: { name: string; count: number }[]
categories: { name: string; count: number }[]
}
export default function SearchPage() {
const searchParams = useSearchParams()
const router = useRouter()
const [query, setQuery] = React.useState(searchParams.get('q') || '')
const [results, setResults] = React.useState<SearchResponse | null>(null)
const [stats, setStats] = React.useState<StatsResponse | null>(null)
const [loading, setLoading] = React.useState(false)
const [filters, setFilters] = React.useState({
language: searchParams.get('language') || '',
category: searchParams.get('category') || '',
minStars: searchParams.get('minStars') || '',
sortBy: (searchParams.get('sortBy') as 'relevance' | 'stars' | 'recent') || 'relevance'
})
// Fetch stats for filters
React.useEffect(() => {
fetch('/api/stats')
.then(res => res.json())
.then(data => setStats(data))
.catch(err => console.error('Failed to fetch stats:', err))
}, [])
// Perform search
const performSearch = React.useCallback((searchQuery: string, page = 1) => {
if (!searchQuery.trim()) {
setResults(null)
return
}
setLoading(true)
const params = new URLSearchParams({
q: searchQuery,
page: page.toString(),
...Object.fromEntries(
Object.entries(filters).filter(([_, v]) => v !== '')
)
})
fetch(`/api/search?${params}`)
.then(res => res.json())
.then(data => {
// Check if response is an error
if (data.error || !data.results) {
console.error('Search API error:', data.error || 'Invalid response')
setResults(null)
return
}
setResults(data)
// Update URL
router.push(`/search?${params}`)
})
.catch(err => {
console.error('Search failed:', err)
setResults(null)
})
.finally(() => setLoading(false))
}, [filters, router])
// Search on query change (debounced)
React.useEffect(() => {
const initialQuery = searchParams.get('q')
if (initialQuery) {
setQuery(initialQuery)
performSearch(initialQuery, parseInt(searchParams.get('page') || '1'))
}
}, []) // Only on mount
const handleSearch = (e: React.FormEvent) => {
e.preventDefault()
performSearch(query)
}
const handleFilterChange = (key: string, value: string) => {
setFilters(prev => ({ ...prev, [key]: value }))
}
React.useEffect(() => {
if (query) {
performSearch(query)
}
}, [filters.sortBy, filters.language, filters.category, filters.minStars])
return (
<div className="min-h-screen bg-linear-to-br from-background via-background to-primary/5">
{/* Header */}
<div className="border-b bg-background/80 backdrop-blur-sm">
<div className="mx-auto max-w-7xl px-6 py-6">
<h1 className="gradient-text mb-4 text-3xl font-bold">Search Awesome</h1>
{/* Search Form */}
<form onSubmit={handleSearch} className="flex gap-2">
<div className="relative flex-1">
<Search className="absolute left-3 top-1/2 h-5 w-5 -translate-y-1/2 text-muted-foreground" />
<Input
type="search"
placeholder="Search repositories, topics, descriptions..."
value={query}
onChange={(e) => setQuery(e.target.value)}
className="pl-10 text-base"
autoFocus
/>
</div>
<Button type="submit" className="btn-awesome" disabled={loading}>
<Search className="mr-2 h-4 w-4" />
Search
</Button>
</form>
{/* Quick Filters */}
<div className="mt-4 flex flex-wrap items-center gap-2">
<Select value={filters.sortBy} onValueChange={(v: string) => handleFilterChange('sortBy', v)}>
<SelectTrigger className="w-[140px]">
<SelectValue placeholder="Sort by" />
</SelectTrigger>
<SelectContent>
<SelectItem value="relevance">Relevance</SelectItem>
<SelectItem value="stars">Most Stars</SelectItem>
<SelectItem value="recent">Recent</SelectItem>
</SelectContent>
</Select>
{/* Mobile Filter Sheet */}
<Sheet>
<SheetTrigger asChild>
<Button variant="outline" size="default">
<SlidersHorizontal className="mr-2 h-4 w-4" />
Filters
{(filters.language || filters.category || filters.minStars) && (
<Badge variant="secondary" className="ml-2">
{[filters.language, filters.category, filters.minStars].filter(Boolean).length}
</Badge>
)}
</Button>
</SheetTrigger>
<SheetContent>
<SheetHeader>
<SheetTitle>Filters</SheetTitle>
<SheetDescription>
Refine your search results
</SheetDescription>
</SheetHeader>
<div className="mt-6 space-y-4">
<div>
<label className="mb-2 block text-sm font-medium">Language</label>
<Select value={filters.language || 'all'} onValueChange={(v: string) => handleFilterChange('language', v === 'all' ? '' : v)}>
<SelectTrigger>
<SelectValue placeholder="All languages" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All languages</SelectItem>
{stats?.languages.slice(0, 20).map(lang => (
<SelectItem key={lang.name} value={lang.name}>
{lang.name} ({lang.count})
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div>
<label className="mb-2 block text-sm font-medium">Category</label>
<Select value={filters.category || 'all'} onValueChange={(v: string) => handleFilterChange('category', v === 'all' ? '' : v)}>
<SelectTrigger>
<SelectValue placeholder="All categories" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All categories</SelectItem>
{stats?.categories.map(cat => (
<SelectItem key={cat.name} value={cat.name}>
{cat.name} ({cat.count})
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div>
<label className="mb-2 block text-sm font-medium">Minimum Stars</label>
<Select value={filters.minStars || 'any'} onValueChange={(v: string) => handleFilterChange('minStars', v === 'any' ? '' : v)}>
<SelectTrigger>
<SelectValue placeholder="Any" />
</SelectTrigger>
<SelectContent>
<SelectItem value="any">Any</SelectItem>
<SelectItem value="100">100+</SelectItem>
<SelectItem value="500">500+</SelectItem>
<SelectItem value="1000">1,000+</SelectItem>
<SelectItem value="5000">5,000+</SelectItem>
<SelectItem value="10000">10,000+</SelectItem>
</SelectContent>
</Select>
</div>
<Button
variant="outline"
className="w-full"
onClick={() => setFilters({ language: '', category: '', minStars: '', sortBy: 'relevance' })}
>
Clear Filters
</Button>
</div>
</SheetContent>
</Sheet>
</div>
</div>
</div>
{/* Results */}
<div className="mx-auto max-w-7xl px-6 py-8">
{loading && (
<div className="space-y-4">
{[...Array(5)].map((_, i) => (
<div key={i} className="rounded-lg border bg-card p-6">
<Skeleton className="mb-2 h-6 w-2/3" />
<Skeleton className="mb-4 h-4 w-full" />
<div className="flex gap-2">
<Skeleton className="h-6 w-20" />
<Skeleton className="h-6 w-16" />
</div>
</div>
))}
</div>
)}
{!loading && results && results.total !== undefined && (
<>
<div className="mb-6 text-muted-foreground">
Found <strong>{results.total.toLocaleString()}</strong> results
{query && <> for &quot;<strong>{query}</strong>&quot;</>}
</div>
<div className="space-y-4">
{results.results.map((result) => (
<div key={result.repository_id} className="card-awesome rounded-lg bg-card p-6">
<div className="mb-2 flex items-start justify-between gap-4">
<h3 className="text-xl font-semibold">
<a
href={result.repository_url}
target="_blank"
rel="noopener noreferrer"
className="group inline-flex items-center gap-2 text-primary hover:text-primary/80"
>
{result.repository_name}
<ExternalLink className="h-4 w-4 opacity-0 transition-opacity group-hover:opacity-100" />
</a>
</h3>
{result.stars !== null && (
<div className="flex items-center gap-1 text-sm text-muted-foreground">
<Star className="h-4 w-4 fill-current text-accent" />
<span>{result.stars.toLocaleString()}</span>
</div>
)}
</div>
{result.description && (
<p className="mb-3 text-muted-foreground">{result.description}</p>
)}
{result.snippet && (
<div
className="mb-3 rounded border-l-2 border-primary/40 bg-muted/50 p-3 text-sm"
dangerouslySetInnerHTML={{ __html: result.snippet }}
/>
)}
<div className="flex flex-wrap gap-2">
{result.language && (
<Badge variant="secondary">{result.language}</Badge>
)}
{result.awesome_list_category && (
<Badge variant="outline">{result.awesome_list_category}</Badge>
)}
{result.awesome_list_name && (
<Badge variant="outline" className="text-xs">
{result.awesome_list_name}
</Badge>
)}
</div>
</div>
))}
</div>
{/* Pagination */}
{results.totalPages > 1 && (
<div className="mt-8 flex items-center justify-center gap-2">
<Button
variant="outline"
disabled={results.page === 1}
onClick={() => performSearch(query, results.page - 1)}
>
Previous
</Button>
<span className="mx-4 text-sm text-muted-foreground">
Page {results.page} of {results.totalPages}
</span>
<Button
variant="outline"
disabled={results.page === results.totalPages}
onClick={() => performSearch(query, results.page + 1)}
>
Next
</Button>
</div>
)}
</>
)}
{!loading && !results && query && (
<div className="py-12 text-center">
<p className="text-lg text-muted-foreground">
Enter a search query to find awesome repositories
</p>
</div>
)}
</div>
</div>
)
}