a new start
This commit is contained in:
49
app/api/db-version/route.ts
Normal file
49
app/api/db-version/route.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
50
app/api/lists/[id]/route.ts
Normal file
50
app/api/lists/[id]/route.ts
Normal 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
24
app/api/lists/route.ts
Normal 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 }
|
||||
)
|
||||
}
|
||||
}
|
||||
36
app/api/repositories/[id]/route.ts
Normal file
36
app/api/repositories/[id]/route.ts
Normal 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
48
app/api/search/route.ts
Normal 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
24
app/api/stats/route.ts
Normal 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
60
app/api/webhook/route.ts
Normal 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
202
app/browse/page.tsx
Normal 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
209
app/disclaimer/page.tsx
Normal 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 ("we", "us", or "our") 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
394
app/globals.css
Normal 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
240
app/imprint/page.tsx
Normal 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'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'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
87
app/layout.tsx
Normal 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
183
app/legal/page.tsx
Normal 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 "mirror" 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'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 "Awesome" 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 'as is' 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
273
app/list/[id]/page.tsx
Normal 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're looking for doesn'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
72
app/my-list/page.tsx
Normal 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
185
app/not-found.tsx
Normal 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've ventured into uncharted territory. This page
|
||||
doesn'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't
|
||||
just a word, it'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
255
app/page.tsx
Normal 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'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>
|
||||
)
|
||||
}
|
||||
98
app/readme/[owner]/[repo]/page.tsx
Normal file
98
app/readme/[owner]/[repo]/page.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
251
app/repository/[id]/page.tsx
Normal file
251
app/repository/[id]/page.tsx
Normal 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're looking for doesn't exist or couldn'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't have a README file or it couldn't be loaded.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
372
app/search/page.tsx
Normal file
372
app/search/page.tsx
Normal 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 "<strong>{query}</strong>"</>}
|
||||
</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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user