mirror of
https://github.com/FranP-code/format_twitter_projects_accounts_tweets.git
synced 2025-10-13 00:32:19 +00:00
Added package-lock.json
This commit is contained in:
committed by
GitHub
parent
d0299ca1a1
commit
5872e1b686
6867
astro-app/package-lock.json
generated
Normal file
6867
astro-app/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -12,6 +12,8 @@
|
|||||||
"@astrojs/mdx": "^4.3.0",
|
"@astrojs/mdx": "^4.3.0",
|
||||||
"@astrojs/react": "^4.3.0",
|
"@astrojs/react": "^4.3.0",
|
||||||
"@tailwindcss/vite": "^4.1.3",
|
"@tailwindcss/vite": "^4.1.3",
|
||||||
|
"@tanstack/react-table": "^8.21.3",
|
||||||
|
"@tanstack/react-virtual": "^3.13.12",
|
||||||
"@types/canvas-confetti": "^1.9.0",
|
"@types/canvas-confetti": "^1.9.0",
|
||||||
"@types/react": "^19.1.8",
|
"@types/react": "^19.1.8",
|
||||||
"@types/react-dom": "^19.1.6",
|
"@types/react-dom": "^19.1.6",
|
||||||
@@ -19,6 +21,8 @@
|
|||||||
"canvas-confetti": "^1.9.3",
|
"canvas-confetti": "^1.9.3",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
|
"csv-parse": "^6.0.0",
|
||||||
|
"date-fns": "^4.1.0",
|
||||||
"lucide-react": "^0.525.0",
|
"lucide-react": "^0.525.0",
|
||||||
"react": "^19.1.0",
|
"react": "^19.1.0",
|
||||||
"react-dom": "^19.1.0",
|
"react-dom": "^19.1.0",
|
||||||
@@ -28,4 +32,4 @@
|
|||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"tw-animate-css": "^1.3.5"
|
"tw-animate-css": "^1.3.5"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
90
astro-app/src/components/ProjectCard.tsx
Normal file
90
astro-app/src/components/ProjectCard.tsx
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
import { ExternalLink, Heart, MessageCircle, Repeat2, Eye } from 'lucide-react';
|
||||||
|
import { formatDistanceToNow } from 'date-fns';
|
||||||
|
import type { TwitterProject } from '@/lib/csv-loader';
|
||||||
|
|
||||||
|
interface ProjectCardProps {
|
||||||
|
project: TwitterProject;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ProjectCard({ project }: ProjectCardProps) {
|
||||||
|
const formattedDate = formatDistanceToNow(new Date(project.created_at), { addSuffix: true });
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="bg-card rounded-lg border p-6 space-y-4 hover:shadow-lg transition-shadow">
|
||||||
|
{/* Author Info */}
|
||||||
|
<div className="flex items-center space-x-3">
|
||||||
|
<div className="w-10 h-10 bg-gradient-to-br from-blue-500 to-purple-600 rounded-full flex items-center justify-center text-white font-semibold">
|
||||||
|
{project.author_name.charAt(0).toUpperCase()}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="font-semibold text-foreground">{project.author_name}</p>
|
||||||
|
<p className="text-sm text-muted-foreground">@{project.author_screen_name}</p>
|
||||||
|
</div>
|
||||||
|
<div className="ml-auto text-xs text-muted-foreground">
|
||||||
|
{formattedDate}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Project Media */}
|
||||||
|
{project.media_thumbnail && (
|
||||||
|
<div className="rounded-lg overflow-hidden">
|
||||||
|
<img
|
||||||
|
src={project.media_thumbnail}
|
||||||
|
alt="Project preview"
|
||||||
|
className="w-full h-48 object-cover"
|
||||||
|
loading="lazy"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Project Description */}
|
||||||
|
<div>
|
||||||
|
<p className="text-foreground leading-relaxed">{project.project_description}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Project URL */}
|
||||||
|
{project.project_url && (
|
||||||
|
<div>
|
||||||
|
<a
|
||||||
|
href={project.project_url}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="inline-flex items-center space-x-2 text-primary hover:text-primary/80 transition-colors"
|
||||||
|
>
|
||||||
|
<ExternalLink className="w-4 h-4" />
|
||||||
|
<span className="text-sm font-medium">View Project</span>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Engagement Stats */}
|
||||||
|
<div className="flex items-center space-x-6 pt-2 border-t">
|
||||||
|
<div className="flex items-center space-x-1 text-muted-foreground">
|
||||||
|
<Heart className="w-4 h-4" />
|
||||||
|
<span className="text-sm">{project.favorite_count.toLocaleString()}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center space-x-1 text-muted-foreground">
|
||||||
|
<Repeat2 className="w-4 h-4" />
|
||||||
|
<span className="text-sm">{project.retweet_count.toLocaleString()}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center space-x-1 text-muted-foreground">
|
||||||
|
<MessageCircle className="w-4 h-4" />
|
||||||
|
<span className="text-sm">{project.reply_count.toLocaleString()}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center space-x-1 text-muted-foreground">
|
||||||
|
<Eye className="w-4 h-4" />
|
||||||
|
<span className="text-sm">{project.views_count.toLocaleString()}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Category */}
|
||||||
|
{project.category && (
|
||||||
|
<div className="flex justify-end">
|
||||||
|
<span className="inline-block px-2 py-1 text-xs font-medium bg-secondary text-secondary-foreground rounded-full">
|
||||||
|
{project.category}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
303
astro-app/src/components/ProjectsTable.tsx
Normal file
303
astro-app/src/components/ProjectsTable.tsx
Normal file
@@ -0,0 +1,303 @@
|
|||||||
|
import { useState, useMemo } from 'react';
|
||||||
|
import { useVirtualizer } from '@tanstack/react-virtual';
|
||||||
|
import {
|
||||||
|
createColumnHelper,
|
||||||
|
flexRender,
|
||||||
|
getCoreRowModel,
|
||||||
|
getFilteredRowModel,
|
||||||
|
getSortedRowModel,
|
||||||
|
useReactTable,
|
||||||
|
type SortingState,
|
||||||
|
type ColumnFiltersState
|
||||||
|
} from '@tanstack/react-table';
|
||||||
|
import { ArrowUpDown, ArrowUp, ArrowDown, Search, Filter, ExternalLink } from 'lucide-react';
|
||||||
|
import { formatDistanceToNow } from 'date-fns';
|
||||||
|
import type { TwitterProject } from '@/lib/csv-loader';
|
||||||
|
import { Button } from './ui/button';
|
||||||
|
import { Input } from './ui/input';
|
||||||
|
import { Select } from './ui/select';
|
||||||
|
|
||||||
|
interface ProjectsTableProps {
|
||||||
|
projects: TwitterProject[];
|
||||||
|
title: string;
|
||||||
|
showUrlColumn?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const columnHelper = createColumnHelper<TwitterProject>();
|
||||||
|
|
||||||
|
export function ProjectsTable({ projects, title, showUrlColumn = true }: ProjectsTableProps) {
|
||||||
|
const [sorting, setSorting] = useState<SortingState>([]);
|
||||||
|
const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([]);
|
||||||
|
const [globalFilter, setGlobalFilter] = useState('');
|
||||||
|
|
||||||
|
const columns = useMemo(() => [
|
||||||
|
columnHelper.accessor('author_name', {
|
||||||
|
header: ({ column }) => (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() => column.toggleSorting(column.getIsSorted() === 'asc')}
|
||||||
|
className="h-auto p-0 font-semibold hover:bg-transparent"
|
||||||
|
>
|
||||||
|
Author
|
||||||
|
{column.getIsSorted() === 'asc' ? (
|
||||||
|
<ArrowUp className="ml-2 h-4 w-4" />
|
||||||
|
) : column.getIsSorted() === 'desc' ? (
|
||||||
|
<ArrowDown className="ml-2 h-4 w-4" />
|
||||||
|
) : (
|
||||||
|
<ArrowUpDown className="ml-2 h-4 w-4" />
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
),
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<div className="flex items-center space-x-3">
|
||||||
|
<div className="w-8 h-8 bg-gradient-to-br from-blue-500 to-purple-600 rounded-full flex items-center justify-center text-white text-sm font-semibold">
|
||||||
|
{row.original.author_name.charAt(0).toUpperCase()}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="font-medium">{row.original.author_name}</div>
|
||||||
|
<div className="text-sm text-muted-foreground">@{row.original.author_screen_name}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
}),
|
||||||
|
columnHelper.accessor('project_description', {
|
||||||
|
header: ({ column }) => (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() => column.toggleSorting(column.getIsSorted() === 'asc')}
|
||||||
|
className="h-auto p-0 font-semibold hover:bg-transparent"
|
||||||
|
>
|
||||||
|
Description
|
||||||
|
{column.getIsSorted() === 'asc' ? (
|
||||||
|
<ArrowUp className="ml-2 h-4 w-4" />
|
||||||
|
) : column.getIsSorted() === 'desc' ? (
|
||||||
|
<ArrowDown className="ml-2 h-4 w-4" />
|
||||||
|
) : (
|
||||||
|
<ArrowUpDown className="ml-2 h-4 w-4" />
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
),
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<div className="max-w-md">
|
||||||
|
<p className="text-sm leading-relaxed line-clamp-3">{row.original.project_description}</p>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
}),
|
||||||
|
...(showUrlColumn ? [
|
||||||
|
columnHelper.accessor('project_url', {
|
||||||
|
header: 'Project',
|
||||||
|
cell: ({ row }) =>
|
||||||
|
row.original.project_url ? (
|
||||||
|
<a
|
||||||
|
href={row.original.project_url}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="inline-flex items-center space-x-1 text-primary hover:text-primary/80 transition-colors"
|
||||||
|
>
|
||||||
|
<ExternalLink className="w-4 h-4" />
|
||||||
|
<span className="text-sm">View</span>
|
||||||
|
</a>
|
||||||
|
) : (
|
||||||
|
<span className="text-muted-foreground text-sm">No URL</span>
|
||||||
|
),
|
||||||
|
})
|
||||||
|
] : []),
|
||||||
|
columnHelper.accessor('media_thumbnail', {
|
||||||
|
header: 'Media',
|
||||||
|
cell: ({ row }) =>
|
||||||
|
row.original.media_thumbnail ? (
|
||||||
|
<img
|
||||||
|
src={row.original.media_thumbnail}
|
||||||
|
alt="Project preview"
|
||||||
|
className="w-16 h-16 object-cover rounded-lg"
|
||||||
|
loading="lazy"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="w-16 h-16 bg-muted rounded-lg flex items-center justify-center">
|
||||||
|
<span className="text-xs text-muted-foreground">No media</span>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
}),
|
||||||
|
columnHelper.accessor('created_at', {
|
||||||
|
header: ({ column }) => (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() => column.toggleSorting(column.getIsSorted() === 'asc')}
|
||||||
|
className="h-auto p-0 font-semibold hover:bg-transparent"
|
||||||
|
>
|
||||||
|
Date
|
||||||
|
{column.getIsSorted() === 'asc' ? (
|
||||||
|
<ArrowUp className="ml-2 h-4 w-4" />
|
||||||
|
) : column.getIsSorted() === 'desc' ? (
|
||||||
|
<ArrowDown className="ml-2 h-4 w-4" />
|
||||||
|
) : (
|
||||||
|
<ArrowUpDown className="ml-2 h-4 w-4" />
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
),
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<div className="text-sm">
|
||||||
|
{formatDistanceToNow(new Date(row.original.created_at), { addSuffix: true })}
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
}),
|
||||||
|
columnHelper.accessor('favorite_count', {
|
||||||
|
header: ({ column }) => (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() => column.toggleSorting(column.getIsSorted() === 'asc')}
|
||||||
|
className="h-auto p-0 font-semibold hover:bg-transparent"
|
||||||
|
>
|
||||||
|
Likes
|
||||||
|
{column.getIsSorted() === 'asc' ? (
|
||||||
|
<ArrowUp className="ml-2 h-4 w-4" />
|
||||||
|
) : column.getIsSorted() === 'desc' ? (
|
||||||
|
<ArrowDown className="ml-2 h-4 w-4" />
|
||||||
|
) : (
|
||||||
|
<ArrowUpDown className="ml-2 h-4 w-4" />
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
),
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<div className="text-sm font-medium">
|
||||||
|
{row.original.favorite_count.toLocaleString()}
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
}),
|
||||||
|
columnHelper.accessor('category', {
|
||||||
|
header: ({ column }) => (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() => column.toggleSorting(column.getIsSorted() === 'asc')}
|
||||||
|
className="h-auto p-0 font-semibold hover:bg-transparent"
|
||||||
|
>
|
||||||
|
Category
|
||||||
|
{column.getIsSorted() === 'asc' ? (
|
||||||
|
<ArrowUp className="ml-2 h-4 w-4" />
|
||||||
|
) : column.getIsSorted() === 'desc' ? (
|
||||||
|
<ArrowDown className="ml-2 h-4 w-4" />
|
||||||
|
) : (
|
||||||
|
<ArrowUpDown className="ml-2 h-4 w-4" />
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
),
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<span className="inline-block px-2 py-1 text-xs font-medium bg-secondary text-secondary-foreground rounded-full">
|
||||||
|
{row.original.category}
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
}),
|
||||||
|
], [showUrlColumn]);
|
||||||
|
|
||||||
|
const table = useReactTable({
|
||||||
|
data: projects,
|
||||||
|
columns,
|
||||||
|
getCoreRowModel: getCoreRowModel(),
|
||||||
|
getSortedRowModel: getSortedRowModel(),
|
||||||
|
getFilteredRowModel: getFilteredRowModel(),
|
||||||
|
onSortingChange: setSorting,
|
||||||
|
onColumnFiltersChange: setColumnFilters,
|
||||||
|
onGlobalFilterChange: setGlobalFilter,
|
||||||
|
state: {
|
||||||
|
sorting,
|
||||||
|
columnFilters,
|
||||||
|
globalFilter,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const parentRef = useState<HTMLDivElement | null>(null);
|
||||||
|
const virtualizer = useVirtualizer({
|
||||||
|
count: table.getRowModel().rows.length,
|
||||||
|
getScrollElement: () => parentRef[0],
|
||||||
|
estimateSize: () => 80,
|
||||||
|
overscan: 5,
|
||||||
|
});
|
||||||
|
|
||||||
|
const categories = useMemo(() => {
|
||||||
|
const cats = Array.from(new Set(projects.map(p => p.category || 'Uncategorized')));
|
||||||
|
return cats.sort();
|
||||||
|
}, [projects]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<h2 className="text-2xl font-bold">{title}</h2>
|
||||||
|
<div className="text-sm text-muted-foreground">
|
||||||
|
{table.getFilteredRowModel().rows.length} of {projects.length} projects
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Filters */}
|
||||||
|
<div className="flex items-center space-x-4">
|
||||||
|
<div className="flex-1 relative">
|
||||||
|
<Search className="absolute left-3 top-3 h-4 w-4 text-muted-foreground" />
|
||||||
|
<Input
|
||||||
|
placeholder="Search projects..."
|
||||||
|
value={globalFilter}
|
||||||
|
onChange={(e) => setGlobalFilter(e.target.value)}
|
||||||
|
className="pl-10"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="w-48">
|
||||||
|
<Select
|
||||||
|
value={(table.getColumn('category')?.getFilterValue() as string) ?? ''}
|
||||||
|
onChange={(e) =>
|
||||||
|
table.getColumn('category')?.setFilterValue(e.target.value === 'all' ? '' : e.target.value)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<option value="all">All Categories</option>
|
||||||
|
{categories.map((category) => (
|
||||||
|
<option key={category} value={category}>
|
||||||
|
{category}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Table */}
|
||||||
|
<div className="border rounded-lg">
|
||||||
|
<div className="overflow-auto" style={{ height: '600px' }} ref={parentRef[1]}>
|
||||||
|
<table className="w-full">
|
||||||
|
<thead className="bg-muted/50 sticky top-0 z-10">
|
||||||
|
{table.getHeaderGroups().map((headerGroup) => (
|
||||||
|
<tr key={headerGroup.id}>
|
||||||
|
{headerGroup.headers.map((header) => (
|
||||||
|
<th key={header.id} className="h-12 px-4 text-left align-middle font-medium">
|
||||||
|
{header.isPlaceholder
|
||||||
|
? null
|
||||||
|
: flexRender(header.column.columnDef.header, header.getContext())}
|
||||||
|
</th>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</thead>
|
||||||
|
<tbody className="relative">
|
||||||
|
<tr style={{ height: `${virtualizer.getTotalSize()}px` }}>
|
||||||
|
<td></td>
|
||||||
|
</tr>
|
||||||
|
{virtualizer.getVirtualItems().map((virtualRow) => {
|
||||||
|
const row = table.getRowModel().rows[virtualRow.index];
|
||||||
|
return (
|
||||||
|
<tr
|
||||||
|
key={row.id}
|
||||||
|
className="border-b transition-colors hover:bg-muted/50 absolute w-full"
|
||||||
|
style={{
|
||||||
|
transform: `translateY(${virtualRow.start}px)`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{row.getVisibleCells().map((cell) => (
|
||||||
|
<td key={cell.id} className="p-4 align-middle">
|
||||||
|
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
||||||
|
</td>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
117
astro-app/src/components/ProjectsView.tsx
Normal file
117
astro-app/src/components/ProjectsView.tsx
Normal file
@@ -0,0 +1,117 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from './ui/tabs';
|
||||||
|
import { ProjectsTable } from './ProjectsTable';
|
||||||
|
import { ProjectCard } from './ProjectCard';
|
||||||
|
import { ThemeToggle } from './ThemeToggle';
|
||||||
|
import { LayoutGrid, Table } from 'lucide-react';
|
||||||
|
import { Button } from './ui/button';
|
||||||
|
import type { TwitterProject } from '@/lib/csv-loader';
|
||||||
|
|
||||||
|
interface ProjectsViewProps {
|
||||||
|
projects: TwitterProject[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ProjectsView({ projects }: ProjectsViewProps) {
|
||||||
|
const [viewMode, setViewMode] = useState<'table' | 'cards'>('table');
|
||||||
|
|
||||||
|
// Separate projects with and without URLs
|
||||||
|
const projectsWithUrls = projects.filter(p => p.project_url);
|
||||||
|
const projectsWithoutUrls = projects.filter(p => !p.project_url);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-background">
|
||||||
|
<div className="container mx-auto px-6 py-8">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between mb-8">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-4xl font-bold text-foreground mb-2">Twitter Projects</h1>
|
||||||
|
<p className="text-muted-foreground">
|
||||||
|
Discover and explore amazing projects shared on Twitter
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center space-x-4">
|
||||||
|
<div className="flex items-center border rounded-lg">
|
||||||
|
<Button
|
||||||
|
variant={viewMode === 'table' ? 'default' : 'ghost'}
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setViewMode('table')}
|
||||||
|
className="rounded-r-none"
|
||||||
|
>
|
||||||
|
<Table className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant={viewMode === 'cards' ? 'default' : 'ghost'}
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setViewMode('cards')}
|
||||||
|
className="rounded-l-none"
|
||||||
|
>
|
||||||
|
<LayoutGrid className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<ThemeToggle />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Statistics */}
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-6 mb-8">
|
||||||
|
<div className="bg-card rounded-lg border p-6">
|
||||||
|
<div className="text-2xl font-bold text-foreground">{projects.length}</div>
|
||||||
|
<div className="text-muted-foreground">Total Projects</div>
|
||||||
|
</div>
|
||||||
|
<div className="bg-card rounded-lg border p-6">
|
||||||
|
<div className="text-2xl font-bold text-green-600">{projectsWithUrls.length}</div>
|
||||||
|
<div className="text-muted-foreground">With Project URLs</div>
|
||||||
|
</div>
|
||||||
|
<div className="bg-card rounded-lg border p-6">
|
||||||
|
<div className="text-2xl font-bold text-orange-600">{projectsWithoutUrls.length}</div>
|
||||||
|
<div className="text-muted-foreground">Missing URLs</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Content */}
|
||||||
|
<Tabs defaultValue="with-urls" className="space-y-6">
|
||||||
|
<TabsList className="grid w-full grid-cols-2">
|
||||||
|
<TabsTrigger value="with-urls">
|
||||||
|
Projects with URLs ({projectsWithUrls.length})
|
||||||
|
</TabsTrigger>
|
||||||
|
<TabsTrigger value="without-urls">
|
||||||
|
Missing URLs ({projectsWithoutUrls.length})
|
||||||
|
</TabsTrigger>
|
||||||
|
</TabsList>
|
||||||
|
|
||||||
|
<TabsContent value="with-urls" className="space-y-6">
|
||||||
|
{viewMode === 'table' ? (
|
||||||
|
<ProjectsTable
|
||||||
|
projects={projectsWithUrls}
|
||||||
|
title="Projects with URLs"
|
||||||
|
showUrlColumn={true}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||||
|
{projectsWithUrls.map((project) => (
|
||||||
|
<ProjectCard key={project.id} project={project} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
<TabsContent value="without-urls" className="space-y-6">
|
||||||
|
{viewMode === 'table' ? (
|
||||||
|
<ProjectsTable
|
||||||
|
projects={projectsWithoutUrls}
|
||||||
|
title="Projects without URLs"
|
||||||
|
showUrlColumn={false}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||||
|
{projectsWithoutUrls.map((project) => (
|
||||||
|
<ProjectCard key={project.id} project={project} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</TabsContent>
|
||||||
|
</Tabs>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
37
astro-app/src/components/ThemeToggle.tsx
Normal file
37
astro-app/src/components/ThemeToggle.tsx
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
import { Moon, Sun } from 'lucide-react';
|
||||||
|
import { Button } from './ui/button';
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
|
||||||
|
export function ThemeToggle() {
|
||||||
|
const [theme, setTheme] = useState<'light' | 'dark'>('light');
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// Check for saved theme preference or default to system preference
|
||||||
|
const savedTheme = localStorage.getItem('theme') as 'light' | 'dark' | null;
|
||||||
|
const systemTheme = window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
|
||||||
|
const initialTheme = savedTheme || systemTheme;
|
||||||
|
|
||||||
|
setTheme(initialTheme);
|
||||||
|
document.documentElement.classList.toggle('dark', initialTheme === 'dark');
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const toggleTheme = () => {
|
||||||
|
const newTheme = theme === 'light' ? 'dark' : 'light';
|
||||||
|
setTheme(newTheme);
|
||||||
|
localStorage.setItem('theme', newTheme);
|
||||||
|
document.documentElement.classList.toggle('dark', newTheme === 'dark');
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="icon"
|
||||||
|
onClick={toggleTheme}
|
||||||
|
className="h-9 w-9"
|
||||||
|
>
|
||||||
|
<Sun className="h-4 w-4 rotate-0 scale-100 transition-all dark:-rotate-90 dark:scale-0" />
|
||||||
|
<Moon className="absolute h-4 w-4 rotate-90 scale-0 transition-all dark:rotate-0 dark:scale-100" />
|
||||||
|
<span className="sr-only">Toggle theme</span>
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
}
|
||||||
54
astro-app/src/components/ui/button.tsx
Normal file
54
astro-app/src/components/ui/button.tsx
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
import { cva, type VariantProps } from "class-variance-authority"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const buttonVariants = cva(
|
||||||
|
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
default: "bg-primary text-primary-foreground hover:bg-primary/90",
|
||||||
|
destructive:
|
||||||
|
"bg-destructive text-destructive-foreground hover:bg-destructive/90",
|
||||||
|
outline:
|
||||||
|
"border border-input bg-background hover:bg-accent hover:text-accent-foreground",
|
||||||
|
secondary:
|
||||||
|
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
||||||
|
ghost: "hover:bg-accent hover:text-accent-foreground",
|
||||||
|
link: "text-primary underline-offset-4 hover:underline",
|
||||||
|
},
|
||||||
|
size: {
|
||||||
|
default: "h-10 px-4 py-2",
|
||||||
|
sm: "h-9 rounded-md px-3",
|
||||||
|
lg: "h-11 rounded-md px-8",
|
||||||
|
icon: "h-10 w-10",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
variant: "default",
|
||||||
|
size: "default",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
export interface ButtonProps
|
||||||
|
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
|
||||||
|
VariantProps<typeof buttonVariants> {
|
||||||
|
asChild?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
||||||
|
({ className, variant, size, ...props }, ref) => {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
className={cn(buttonVariants({ variant, size, className }))}
|
||||||
|
ref={ref}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
Button.displayName = "Button"
|
||||||
|
|
||||||
|
export { Button, buttonVariants }
|
||||||
25
astro-app/src/components/ui/input.tsx
Normal file
25
astro-app/src/components/ui/input.tsx
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
export interface InputProps
|
||||||
|
extends React.InputHTMLAttributes<HTMLInputElement> {}
|
||||||
|
|
||||||
|
const Input = React.forwardRef<HTMLInputElement, InputProps>(
|
||||||
|
({ className, type, ...props }, ref) => {
|
||||||
|
return (
|
||||||
|
<input
|
||||||
|
type={type}
|
||||||
|
className={cn(
|
||||||
|
"flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
ref={ref}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
Input.displayName = "Input"
|
||||||
|
|
||||||
|
export { Input }
|
||||||
99
astro-app/src/components/ui/select.tsx
Normal file
99
astro-app/src/components/ui/select.tsx
Normal file
@@ -0,0 +1,99 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
import { ChevronDown, Check } from "lucide-react"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const Select = React.forwardRef<
|
||||||
|
HTMLSelectElement,
|
||||||
|
React.SelectHTMLAttributes<HTMLSelectElement>
|
||||||
|
>(({ className, children, ...props }, ref) => (
|
||||||
|
<div className="relative">
|
||||||
|
<select
|
||||||
|
className={cn(
|
||||||
|
"flex h-10 w-full items-center justify-between rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1 appearance-none",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
ref={ref}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</select>
|
||||||
|
<ChevronDown className="absolute right-3 top-3 h-4 w-4 opacity-50 pointer-events-none" />
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
Select.displayName = "Select"
|
||||||
|
|
||||||
|
const SelectContent = React.forwardRef<
|
||||||
|
HTMLDivElement,
|
||||||
|
React.HTMLAttributes<HTMLDivElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"relative z-50 max-h-96 min-w-[8rem] overflow-hidden rounded-md border bg-popover text-popover-foreground shadow-md",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
SelectContent.displayName = "SelectContent"
|
||||||
|
|
||||||
|
const SelectItem = React.forwardRef<
|
||||||
|
HTMLDivElement,
|
||||||
|
React.HTMLAttributes<HTMLDivElement> & {
|
||||||
|
value: string
|
||||||
|
}
|
||||||
|
>(({ className, children, ...props }, ref) => (
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||||
|
<Check className="h-4 w-4" />
|
||||||
|
</span>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
SelectItem.displayName = "SelectItem"
|
||||||
|
|
||||||
|
const SelectTrigger = React.forwardRef<
|
||||||
|
HTMLButtonElement,
|
||||||
|
React.ButtonHTMLAttributes<HTMLButtonElement>
|
||||||
|
>(({ className, children, ...props }, ref) => (
|
||||||
|
<button
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"flex h-10 w-full items-center justify-between rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
<ChevronDown className="h-4 w-4 opacity-50" />
|
||||||
|
</button>
|
||||||
|
))
|
||||||
|
SelectTrigger.displayName = "SelectTrigger"
|
||||||
|
|
||||||
|
const SelectValue = React.forwardRef<
|
||||||
|
HTMLSpanElement,
|
||||||
|
React.HTMLAttributes<HTMLSpanElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<span
|
||||||
|
ref={ref}
|
||||||
|
className={cn("block truncate", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
SelectValue.displayName = "SelectValue"
|
||||||
|
|
||||||
|
export {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
}
|
||||||
104
astro-app/src/components/ui/table.tsx
Normal file
104
astro-app/src/components/ui/table.tsx
Normal file
@@ -0,0 +1,104 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const Table = React.forwardRef<
|
||||||
|
HTMLTableElement,
|
||||||
|
React.HTMLAttributes<HTMLTableElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<div className="relative w-full overflow-auto">
|
||||||
|
<table
|
||||||
|
ref={ref}
|
||||||
|
className={cn("w-full caption-bottom text-sm", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
Table.displayName = "Table"
|
||||||
|
|
||||||
|
const TableHeader = React.forwardRef<
|
||||||
|
HTMLTableSectionElement,
|
||||||
|
React.HTMLAttributes<HTMLTableSectionElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<thead ref={ref} className={cn("[&_tr]:border-b", className)} {...props} />
|
||||||
|
))
|
||||||
|
TableHeader.displayName = "TableHeader"
|
||||||
|
|
||||||
|
const TableBody = React.forwardRef<
|
||||||
|
HTMLTableSectionElement,
|
||||||
|
React.HTMLAttributes<HTMLTableSectionElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<tbody
|
||||||
|
ref={ref}
|
||||||
|
className={cn("[&_tr:last-child]:border-0", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
TableBody.displayName = "TableBody"
|
||||||
|
|
||||||
|
const TableFooter = React.forwardRef<
|
||||||
|
HTMLTableSectionElement,
|
||||||
|
React.HTMLAttributes<HTMLTableSectionElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<tfoot
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"border-t bg-muted/50 font-medium [&>tr]:last:border-b-0",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
TableFooter.displayName = "TableFooter"
|
||||||
|
|
||||||
|
const TableRow = React.forwardRef<
|
||||||
|
HTMLTableRowElement,
|
||||||
|
React.HTMLAttributes<HTMLTableRowElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<tr
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"border-b transition-colors hover:bg-muted/50 data-[state=selected]:bg-muted",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
TableRow.displayName = "TableRow"
|
||||||
|
|
||||||
|
const TableHead = React.forwardRef<
|
||||||
|
HTMLTableCellElement,
|
||||||
|
React.ThHTMLAttributes<HTMLTableCellElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<th
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"h-12 px-4 text-left align-middle font-medium text-muted-foreground [&:has([role=checkbox])]:pr-0",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
TableHead.displayName = "TableHead"
|
||||||
|
|
||||||
|
const TableCell = React.forwardRef<
|
||||||
|
HTMLTableCellElement,
|
||||||
|
React.TdHTMLAttributes<HTMLTableCellElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<td
|
||||||
|
ref={ref}
|
||||||
|
className={cn("p-4 align-middle [&:has([role=checkbox])]:pr-0", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
TableCell.displayName = "TableCell"
|
||||||
|
|
||||||
|
export {
|
||||||
|
Table,
|
||||||
|
TableHeader,
|
||||||
|
TableBody,
|
||||||
|
TableFooter,
|
||||||
|
TableHead,
|
||||||
|
TableRow,
|
||||||
|
TableCell,
|
||||||
|
}
|
||||||
68
astro-app/src/components/ui/tabs.tsx
Normal file
68
astro-app/src/components/ui/tabs.tsx
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const Tabs = React.forwardRef<
|
||||||
|
HTMLDivElement,
|
||||||
|
React.HTMLAttributes<HTMLDivElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
className={cn("w-full", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
Tabs.displayName = "Tabs"
|
||||||
|
|
||||||
|
const TabsList = React.forwardRef<
|
||||||
|
HTMLDivElement,
|
||||||
|
React.HTMLAttributes<HTMLDivElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"inline-flex h-10 items-center justify-center rounded-md bg-muted p-1 text-muted-foreground",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
TabsList.displayName = "TabsList"
|
||||||
|
|
||||||
|
const TabsTrigger = React.forwardRef<
|
||||||
|
HTMLButtonElement,
|
||||||
|
React.ButtonHTMLAttributes<HTMLButtonElement> & {
|
||||||
|
value: string
|
||||||
|
}
|
||||||
|
>(({ className, value, children, ...props }, ref) => (
|
||||||
|
<button
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"inline-flex items-center justify-center whitespace-nowrap rounded-sm px-3 py-1.5 text-sm font-medium ring-offset-background transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow-sm",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</button>
|
||||||
|
))
|
||||||
|
TabsTrigger.displayName = "TabsTrigger"
|
||||||
|
|
||||||
|
const TabsContent = React.forwardRef<
|
||||||
|
HTMLDivElement,
|
||||||
|
React.HTMLAttributes<HTMLDivElement> & {
|
||||||
|
value: string
|
||||||
|
}
|
||||||
|
>(({ className, value, ...props }, ref) => (
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"mt-2 ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
TabsContent.displayName = "TabsContent"
|
||||||
|
|
||||||
|
export { Tabs, TabsList, TabsTrigger, TabsContent }
|
||||||
51
astro-app/src/lib/csv-loader.ts
Normal file
51
astro-app/src/lib/csv-loader.ts
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
import { parse } from 'csv-parse/sync';
|
||||||
|
import fs from 'fs';
|
||||||
|
import path from 'path';
|
||||||
|
|
||||||
|
export interface TwitterProject {
|
||||||
|
id: string;
|
||||||
|
created_at: string;
|
||||||
|
project_description: string;
|
||||||
|
project_url: string | null;
|
||||||
|
media_type: string | null;
|
||||||
|
media_thumbnail: string | null;
|
||||||
|
media_original: string | null;
|
||||||
|
author_screen_name: string;
|
||||||
|
author_name: string;
|
||||||
|
favorite_count: number;
|
||||||
|
retweet_count: number;
|
||||||
|
reply_count: number;
|
||||||
|
views_count: number;
|
||||||
|
category?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function loadTwitterProjects(): Promise<TwitterProject[]> {
|
||||||
|
try {
|
||||||
|
const csvPath = path.join(process.cwd(), '..', 'unified_projects.csv');
|
||||||
|
const csvContent = fs.readFileSync(csvPath, 'utf-8');
|
||||||
|
|
||||||
|
const records = parse(csvContent, {
|
||||||
|
columns: true,
|
||||||
|
skip_empty_lines: true,
|
||||||
|
cast: (value, context) => {
|
||||||
|
// Convert numeric fields
|
||||||
|
if (['favorite_count', 'retweet_count', 'reply_count', 'views_count'].includes(context.column as string)) {
|
||||||
|
return parseInt(value) || 0;
|
||||||
|
}
|
||||||
|
// Handle null/empty values
|
||||||
|
if (value === '' || value === 'null') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return records.map((record: any) => ({
|
||||||
|
...record,
|
||||||
|
category: record.category || 'Uncategorized'
|
||||||
|
}));
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error loading CSV:', error);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
30
astro-app/src/pages/projects.astro
Normal file
30
astro-app/src/pages/projects.astro
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
---
|
||||||
|
import { loadTwitterProjects } from '../lib/csv-loader';
|
||||||
|
import { ProjectsView } from '../components/ProjectsView';
|
||||||
|
import '../styles/global.css';
|
||||||
|
|
||||||
|
// Load projects server-side
|
||||||
|
const projects = await loadTwitterProjects();
|
||||||
|
---
|
||||||
|
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||||
|
<meta name="generator" content={Astro.generator} />
|
||||||
|
<title>Twitter Projects Viewer</title>
|
||||||
|
<meta name="description" content="Explore amazing projects shared on Twitter with advanced filtering and sorting capabilities." />
|
||||||
|
<script>
|
||||||
|
// Initialize theme before content loads to prevent flash
|
||||||
|
const savedTheme = localStorage.getItem('theme');
|
||||||
|
const systemTheme = window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
|
||||||
|
const theme = savedTheme || systemTheme;
|
||||||
|
document.documentElement.classList.toggle('dark', theme === 'dark');
|
||||||
|
</script>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<ProjectsView client:load projects={projects} />
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
</html>
|
||||||
@@ -117,4 +117,11 @@
|
|||||||
body {
|
body {
|
||||||
@apply bg-background text-foreground;
|
@apply bg-background text-foreground;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.line-clamp-3 {
|
||||||
|
display: -webkit-box;
|
||||||
|
-webkit-line-clamp: 3;
|
||||||
|
-webkit-box-orient: vertical;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user