├── .eslintignore ├── .npmrc ├── .eslintrc.json ├── lib ├── constant.ts ├── fetch.ts ├── utils.ts ├── useKeyboardShortcut.ts ├── analyze.ts └── db.ts ├── .graphqlrc.yml ├── app ├── favicon.ico ├── providers.tsx ├── api │ ├── route.ts │ └── gh │ │ ├── user │ │ └── route.ts │ │ ├── oauth │ │ └── callback │ │ │ └── route.ts │ │ ├── users │ │ └── route.ts │ │ └── stars │ │ └── [...] │ │ └── route.ts ├── loading.tsx ├── layout.tsx ├── settings │ ├── layout.tsx │ ├── preferences │ │ └── page.tsx │ ├── indexdb │ │ └── page.tsx │ └── page.tsx ├── globals.css ├── page.tsx └── login │ └── page.tsx ├── next.config.js ├── postcss.config.js ├── store ├── index.ts ├── useStore.ts ├── setting.ts ├── account.ts └── star.ts ├── styles ├── header.module.css ├── repo.module.css └── login.module.css ├── components ├── ui │ ├── skeleton.tsx │ ├── separator.tsx │ ├── input.tsx │ ├── sidebar-nav.tsx │ ├── badge.tsx │ ├── switch.tsx │ ├── hover-card.tsx │ ├── popover.tsx │ ├── avatar.tsx │ ├── button.tsx │ ├── card.tsx │ ├── calendar.tsx │ ├── table.tsx │ ├── dialog.tsx │ ├── select.tsx │ ├── alert-dialog.tsx │ ├── command.tsx │ └── dropdown-menu.tsx ├── theme-provider.tsx ├── motion.tsx ├── logo-icon.tsx ├── layout │ ├── footer.tsx │ └── header.tsx ├── toggle-theme.tsx ├── date-picker.tsx ├── search.tsx ├── pagination.tsx ├── account.tsx ├── analyze.tsx ├── user-search.tsx └── repo-list.tsx ├── components.json ├── .gitignore ├── public ├── logo-dark.svg └── logo-light.svg ├── tsconfig.json ├── package.json ├── README.md ├── resource └── add-to-stargazers.user.js └── tailwind.config.js /.eslintignore: -------------------------------------------------------------------------------- 1 | *.json 2 | -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | registry=https://registry.npmmirror.com/ 2 | -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "next/core-web-vitals" 3 | } 4 | -------------------------------------------------------------------------------- /lib/constant.ts: -------------------------------------------------------------------------------- 1 | export const GITHUB_CLIENT_ID="Iv1.64883b45277a4b8c" 2 | -------------------------------------------------------------------------------- /.graphqlrc.yml: -------------------------------------------------------------------------------- 1 | schema: './node_modules/@octokit/graphql-schema/schema.graphql' 2 | -------------------------------------------------------------------------------- /app/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yuyinws/stargazers/HEAD/app/favicon.ico -------------------------------------------------------------------------------- /lib/fetch.ts: -------------------------------------------------------------------------------- 1 | export const fetcher = (url:string) => fetch(url).then((res) => res.json()); 2 | -------------------------------------------------------------------------------- /next.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('next').NextConfig} */ 2 | const nextConfig = {} 3 | 4 | module.exports = nextConfig 5 | -------------------------------------------------------------------------------- /postcss.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: { 3 | tailwindcss: {}, 4 | autoprefixer: {}, 5 | }, 6 | } 7 | -------------------------------------------------------------------------------- /store/index.ts: -------------------------------------------------------------------------------- 1 | export * from './account' 2 | export * from './star' 3 | export * from './useStore' 4 | export * from './setting' 5 | -------------------------------------------------------------------------------- /lib/utils.ts: -------------------------------------------------------------------------------- 1 | import { type ClassValue, clsx } from "clsx" 2 | import { twMerge } from "tailwind-merge" 3 | 4 | export function cn(...inputs: ClassValue[]) { 5 | return twMerge(clsx(inputs)) 6 | } 7 | -------------------------------------------------------------------------------- /app/providers.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { Toaster } from "sonner"; 4 | 5 | export function Providers({ children }: { children: React.ReactNode }) { 6 | return ( 7 | <> 8 | 9 | {children} 10 | 11 | ); 12 | } 13 | -------------------------------------------------------------------------------- /app/api/route.ts: -------------------------------------------------------------------------------- 1 | import { NextResponse } from 'next/server' 2 | 3 | export async function GET(request: Request) { 4 | const { searchParams } = new URL(request.url) 5 | const code = searchParams.get('code') 6 | 7 | return NextResponse.json({ 8 | code, 9 | }) 10 | } 11 | -------------------------------------------------------------------------------- /styles/header.module.css: -------------------------------------------------------------------------------- 1 | .header { 2 | background-size: 4px 4px; 3 | -webkit-backdrop-filter: saturate(50%) blur(4px); 4 | backdrop-filter: saturate(50%) blur(4px); 5 | background-image: radial-gradient(transparent 1px,var(--background) 1px); 6 | z-index: 100; 7 | border-bottom: 1px solid hsl(var(--border)); 8 | } 9 | -------------------------------------------------------------------------------- /components/ui/skeleton.tsx: -------------------------------------------------------------------------------- 1 | import { cn } from "@/lib/utils" 2 | 3 | function Skeleton({ 4 | className, 5 | ...props 6 | }: React.HTMLAttributes) { 7 | return ( 8 |
12 | ) 13 | } 14 | 15 | export { Skeleton } 16 | -------------------------------------------------------------------------------- /components/theme-provider.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import * as React from "react"; 4 | import { ThemeProvider as NextThemesProvider } from "next-themes"; 5 | import { type ThemeProviderProps } from "next-themes/dist/types"; 6 | 7 | export function ThemeProvider({ children, ...props }: ThemeProviderProps) { 8 | return {children}; 9 | } 10 | -------------------------------------------------------------------------------- /components.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://ui.shadcn.com/schema.json", 3 | "style": "default", 4 | "rsc": true, 5 | "tsx": true, 6 | "tailwind": { 7 | "config": "tailwind.config.js", 8 | "css": "app/globals.css", 9 | "baseColor": "slate", 10 | "cssVariables": true 11 | }, 12 | "aliases": { 13 | "components": "@/components", 14 | "utils": "@/lib/utils" 15 | } 16 | } -------------------------------------------------------------------------------- /store/useStore.ts: -------------------------------------------------------------------------------- 1 | import { useState, useEffect } from 'react'; 2 | 3 | export const useStore = ( 4 | store: (callback: (state: T) => unknown) => unknown, 5 | callback: (state: T) => F 6 | ) => { 7 | const result = store(callback) as F; 8 | const [data, setData] = useState(); 9 | 10 | useEffect(() => { 11 | setData(result); 12 | }, [result]); 13 | 14 | return data; 15 | }; 16 | -------------------------------------------------------------------------------- /styles/repo.module.css: -------------------------------------------------------------------------------- 1 | .description { 2 | overflow: hidden; 3 | text-overflow: ellipsis; 4 | display: -webkit-box; 5 | -webkit-line-clamp: 2; 6 | -webkit-box-orient: vertical; 7 | text-wrap: balance; 8 | } 9 | 10 | .repo-name { 11 | overflow: hidden; 12 | text-overflow: ellipsis; 13 | display: -webkit-box; 14 | -webkit-line-clamp: 2; 15 | -webkit-box-orient: vertical; 16 | } 17 | -------------------------------------------------------------------------------- /components/motion.tsx: -------------------------------------------------------------------------------- 1 | import { motion } from "framer-motion"; 2 | 3 | export function FadeInWhenVisible({ children }: { children: React.ReactNode }) { 4 | return ( 5 | 15 | {children} 16 | 17 | ); 18 | } 19 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | 8 | # testing 9 | /coverage 10 | 11 | # next.js 12 | /.next/ 13 | /out/ 14 | 15 | # production 16 | /build 17 | 18 | # misc 19 | .DS_Store 20 | *.pem 21 | 22 | # debug 23 | npm-debug.log* 24 | yarn-debug.log* 25 | yarn-error.log* 26 | 27 | # local env files 28 | .env*.local 29 | 30 | # vercel 31 | .vercel 32 | 33 | # typescript 34 | *.tsbuildinfo 35 | next-env.d.ts 36 | .env 37 | -------------------------------------------------------------------------------- /public/logo-dark.svg: -------------------------------------------------------------------------------- 1 | 12 | -------------------------------------------------------------------------------- /public/logo-light.svg: -------------------------------------------------------------------------------- 1 | 12 | -------------------------------------------------------------------------------- /styles/login.module.css: -------------------------------------------------------------------------------- 1 | .title { 2 | background: -webkit-linear-gradient(315deg, #00dfd8 25%, #007cf0); 3 | background-clip: text; 4 | -webkit-background-clip: text; 5 | -webkit-text-fill-color: transparent; 6 | font-weight: bold; 7 | background-size: 400% 400%; 8 | animation: gradient 12s ease infinite; 9 | } 10 | 11 | @keyframes gradient { 12 | 0% { 13 | background-position: 0% 50%; 14 | } 15 | 16 | 50% { 17 | background-position: 100% 50%; 18 | } 19 | 20 | 100% { 21 | background-position: 0% 50%; 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /app/loading.tsx: -------------------------------------------------------------------------------- 1 | import { Skeleton } from "@/components/ui/skeleton"; 2 | import { Loader2Icon } from "lucide-react"; 3 | 4 | export default function Loading() { 5 | // create a full screen mask loading 6 | return ( 7 | <> 8 |
9 | 10 |
11 |
12 | 13 | ); 14 | } 15 | -------------------------------------------------------------------------------- /components/logo-icon.tsx: -------------------------------------------------------------------------------- 1 | export default function LogoIcon() { 2 | return ( 3 | 12 | ); 13 | } 14 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es5", 4 | "lib": ["dom", "dom.iterable", "esnext"], 5 | "allowJs": true, 6 | "skipLibCheck": true, 7 | "strict": true, 8 | "forceConsistentCasingInFileNames": true, 9 | "noEmit": true, 10 | "esModuleInterop": true, 11 | "module": "esnext", 12 | "moduleResolution": "bundler", 13 | "resolveJsonModule": true, 14 | "isolatedModules": true, 15 | "jsx": "preserve", 16 | "incremental": true, 17 | "plugins": [ 18 | { 19 | "name": "next" 20 | } 21 | ], 22 | "paths": { 23 | "@/*": ["./*"] 24 | } 25 | }, 26 | "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], 27 | "exclude": ["node_modules"] 28 | } 29 | -------------------------------------------------------------------------------- /store/setting.ts: -------------------------------------------------------------------------------- 1 | import { create } from 'zustand' 2 | import { persist } from 'zustand/middleware' 3 | import { useStarStore } from '@/store' 4 | 5 | interface Setting { 6 | autoSwitch: boolean 7 | dateRange: string 8 | } 9 | 10 | interface SettingStore { 11 | settings: Setting 12 | setSettings(settings: Partial): void 13 | } 14 | 15 | export const useSettingStore = create()( 16 | persist( 17 | (set, get) => ({ 18 | settings: { 19 | autoSwitch: true, 20 | dateRange: '2' 21 | }, 22 | setSettings(settings: Partial) { 23 | set(() => ({ 24 | settings: { 25 | ...get().settings, 26 | ...settings 27 | } 28 | })) 29 | } 30 | }), 31 | { 32 | name: 'setting' 33 | } 34 | ) 35 | ) 36 | -------------------------------------------------------------------------------- /app/api/gh/user/route.ts: -------------------------------------------------------------------------------- 1 | import { NextResponse } from 'next/server' 2 | export const dynamic = 'force-dynamic' 3 | export async function GET(request: Request) { 4 | try { 5 | const { searchParams } = new URL(request.url) 6 | const access_token = searchParams.get('access_token') 7 | 8 | const userResponse = await fetch('https://api.github.com/user', { 9 | method: 'GET', 10 | headers: { 11 | "Content-Type": "application/json", 12 | "Accept": 'application/json', 13 | 'Authorization': `Bearer ${access_token}` 14 | }, 15 | }) 16 | 17 | const user = await userResponse.json() 18 | 19 | return NextResponse.json({ 20 | state: 'success', 21 | user 22 | }) 23 | 24 | } catch (error) { 25 | return NextResponse.json({ 26 | state: 'error', 27 | error 28 | }) 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /lib/useKeyboardShortcut.ts: -------------------------------------------------------------------------------- 1 | import { useEffect } from "react"; 2 | 3 | type Key = "ctrl" | "shift" | "alt" | string; 4 | 5 | export const useKeyboardShortcut = ( 6 | keys: Key[], 7 | callback: () => void 8 | ) => { 9 | useEffect(() => { 10 | const handleKeyDown = (event: KeyboardEvent) => { 11 | if ( 12 | keys.every( 13 | (key) => 14 | (key === "ctrl" && event.ctrlKey) || 15 | (key === "shift" && event.shiftKey) || 16 | (key === "alt" && event.altKey) || 17 | (typeof key === "string" && event.key.toLowerCase() === key) 18 | ) 19 | ) { 20 | callback(); 21 | } 22 | }; 23 | 24 | window.addEventListener("keydown", handleKeyDown); 25 | 26 | return () => { 27 | window.removeEventListener("keydown", handleKeyDown); 28 | }; 29 | }, [keys, callback]); 30 | }; 31 | -------------------------------------------------------------------------------- /components/ui/separator.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import * as React from "react" 4 | import * as SeparatorPrimitive from "@radix-ui/react-separator" 5 | 6 | import { cn } from "@/lib/utils" 7 | 8 | const Separator = React.forwardRef< 9 | React.ElementRef, 10 | React.ComponentPropsWithoutRef 11 | >( 12 | ( 13 | { className, orientation = "horizontal", decorative = true, ...props }, 14 | ref 15 | ) => ( 16 | 27 | ) 28 | ) 29 | Separator.displayName = SeparatorPrimitive.Root.displayName 30 | 31 | export { Separator } 32 | -------------------------------------------------------------------------------- /components/ui/input.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | 3 | import { cn } from "@/lib/utils" 4 | 5 | export interface InputProps 6 | extends React.InputHTMLAttributes {} 7 | 8 | const Input = React.forwardRef( 9 | ({ className, type, ...props }, ref) => { 10 | return ( 11 | 20 | ) 21 | } 22 | ) 23 | Input.displayName = "Input" 24 | 25 | export { Input } 26 | -------------------------------------------------------------------------------- /components/layout/footer.tsx: -------------------------------------------------------------------------------- 1 | import Link from "next/link"; 2 | import { Separator } from "@/components/ui/separator"; 3 | 4 | export default function Footer() { 5 | return ( 6 |
7 | 8 |
9 |
10 | Made by 11 | 16 | yuyinws 17 | 18 |
19 |
20 | 25 | GitHub source code 26 | 27 |
28 |
29 |
30 | ); 31 | } 32 | -------------------------------------------------------------------------------- /app/api/gh/oauth/callback/route.ts: -------------------------------------------------------------------------------- 1 | import { NextResponse } from 'next/server' 2 | export const dynamic = 'force-dynamic' 3 | export async function GET(request: Request) { 4 | try { 5 | const { searchParams } = new URL(request.url) 6 | const code = searchParams.get('code') 7 | 8 | const tokenResponse = await fetch('https://github.com/login/oauth/access_token', { 9 | method: 'POST', 10 | headers: { 11 | "Content-Type": "application/json", 12 | "Accept": 'application/json', 13 | }, 14 | body: JSON.stringify({ 15 | client_id: process.env.GITHUB_CLIENT_ID, 16 | client_secret: process.env.GITHUB_CLIENT_SECRET, 17 | code, 18 | }), 19 | }) 20 | 21 | const { access_token } = await tokenResponse.json() 22 | 23 | const loginUrl = new URL('/login', request.url) 24 | 25 | loginUrl.searchParams.set('access_token', access_token) 26 | 27 | return NextResponse.redirect(loginUrl) 28 | 29 | } catch (error) { 30 | console.log(error) 31 | return NextResponse.json({ error }) 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /app/api/gh/users/route.ts: -------------------------------------------------------------------------------- 1 | import { NextResponse } from 'next/server' 2 | 3 | const query = `#graphql 4 | query GetUsers($query: String!) { 5 | search(type: USER,query: $query,first: 8) { 6 | nodes { 7 | ... on User { 8 | login 9 | name 10 | id 11 | avatarUrl 12 | } 13 | } 14 | } 15 | } 16 | ` 17 | 18 | export const runtime = 'edge' 19 | 20 | export async function GET(request: Request) { 21 | const { searchParams } = new URL(request.url) 22 | const name = searchParams.get('name') 23 | const res = await fetch('https://api.github.com/graphql', { 24 | method: 'POST', 25 | headers: { 26 | 'Content-Type': 'application/json', 27 | 'Accept': 'application/json', 28 | 'Authorization': `Bearer ${process.env.GITHUB_ACCESS_TOKEN}` 29 | }, 30 | body: JSON.stringify({ 31 | query, 32 | variables: { 33 | query: name 34 | }, 35 | }) 36 | }) 37 | 38 | const data = await res.json() 39 | 40 | return NextResponse.json({ 41 | state: 'success', 42 | data, 43 | }) 44 | } 45 | -------------------------------------------------------------------------------- /app/layout.tsx: -------------------------------------------------------------------------------- 1 | import "./globals.css"; 2 | import type { Metadata } from "next"; 3 | import { Inter } from "next/font/google"; 4 | import { ThemeProvider } from "@/components/theme-provider"; 5 | import Header from "@/components/layout/header"; 6 | import { Providers } from "./providers"; 7 | import Footer from "@/components/layout/footer"; 8 | 9 | const inter = Inter({ subsets: ["latin"] }); 10 | 11 | export const metadata: Metadata = { 12 | title: "Stargazers", 13 | description: "Generated by create next app", 14 | }; 15 | 16 | export default function RootLayout({ 17 | children, 18 | }: { 19 | children: React.ReactNode; 20 | }) { 21 | return ( 22 | 23 | 24 | 25 | 26 |
27 |
28 |
{children}
29 |
30 |
31 |
32 |
33 | 34 | 35 | ); 36 | } 37 | -------------------------------------------------------------------------------- /components/layout/header.tsx: -------------------------------------------------------------------------------- 1 | import { ModeToggle } from "@/components/toggle-theme"; 2 | import styles from "@/styles/header.module.css"; 3 | import dynamic from "next/dynamic"; 4 | import Logo from "@/components/logo-icon"; 5 | import Link from "next/link"; 6 | import Image from "next/image"; 7 | 8 | export default function Header() { 9 | const Account = dynamic(() => import("@/components/account"), { 10 | ssr: false, 11 | }); 12 | 13 | return ( 14 |
17 | 18 | logo 25 | logo 32 | 33 |
34 | 35 | 36 |
37 |
38 | ); 39 | } 40 | -------------------------------------------------------------------------------- /components/ui/sidebar-nav.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import Link from "next/link"; 4 | import { usePathname } from "next/navigation"; 5 | 6 | import { cn } from "@/lib/utils"; 7 | import { buttonVariants } from "@/components/ui/button"; 8 | 9 | interface SidebarNavProps extends React.HTMLAttributes { 10 | items: { 11 | href: string; 12 | title: string; 13 | }[]; 14 | } 15 | 16 | export function SidebarNav({ className, items, ...props }: SidebarNavProps) { 17 | const pathname = usePathname(); 18 | 19 | return ( 20 | 43 | ); 44 | } 45 | -------------------------------------------------------------------------------- /components/ui/badge.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | import { cva, type VariantProps } from "class-variance-authority" 3 | 4 | import { cn } from "@/lib/utils" 5 | 6 | const badgeVariants = cva( 7 | "inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2", 8 | { 9 | variants: { 10 | variant: { 11 | default: 12 | "border-transparent bg-primary text-primary-foreground hover:bg-primary/80", 13 | secondary: 14 | "border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80", 15 | destructive: 16 | "border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80", 17 | outline: "text-foreground", 18 | }, 19 | }, 20 | defaultVariants: { 21 | variant: "default", 22 | }, 23 | } 24 | ) 25 | 26 | export interface BadgeProps 27 | extends React.HTMLAttributes, 28 | VariantProps {} 29 | 30 | function Badge({ className, variant, ...props }: BadgeProps) { 31 | return ( 32 |
33 | ) 34 | } 35 | 36 | export { Badge, badgeVariants } 37 | -------------------------------------------------------------------------------- /components/ui/switch.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import * as React from "react" 4 | import * as SwitchPrimitives from "@radix-ui/react-switch" 5 | 6 | import { cn } from "@/lib/utils" 7 | 8 | const Switch = React.forwardRef< 9 | React.ElementRef, 10 | React.ComponentPropsWithoutRef 11 | >(({ className, ...props }, ref) => ( 12 | 20 | 25 | 26 | )) 27 | Switch.displayName = SwitchPrimitives.Root.displayName 28 | 29 | export { Switch } 30 | -------------------------------------------------------------------------------- /components/ui/hover-card.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import * as React from "react" 4 | import * as HoverCardPrimitive from "@radix-ui/react-hover-card" 5 | 6 | import { cn } from "@/lib/utils" 7 | 8 | const HoverCard = HoverCardPrimitive.Root 9 | 10 | const HoverCardTrigger = HoverCardPrimitive.Trigger 11 | 12 | const HoverCardContent = React.forwardRef< 13 | React.ElementRef, 14 | React.ComponentPropsWithoutRef 15 | >(({ className, align = "center", sideOffset = 4, ...props }, ref) => ( 16 | 26 | )) 27 | HoverCardContent.displayName = HoverCardPrimitive.Content.displayName 28 | 29 | export { HoverCard, HoverCardTrigger, HoverCardContent } 30 | -------------------------------------------------------------------------------- /components/ui/popover.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import * as React from "react" 4 | import * as PopoverPrimitive from "@radix-ui/react-popover" 5 | 6 | import { cn } from "@/lib/utils" 7 | 8 | const Popover = PopoverPrimitive.Root 9 | 10 | const PopoverTrigger = PopoverPrimitive.Trigger 11 | 12 | const PopoverContent = React.forwardRef< 13 | React.ElementRef, 14 | React.ComponentPropsWithoutRef 15 | >(({ className, align = "center", sideOffset = 4, ...props }, ref) => ( 16 | 17 | 27 | 28 | )) 29 | PopoverContent.displayName = PopoverPrimitive.Content.displayName 30 | 31 | export { Popover, PopoverTrigger, PopoverContent } 32 | -------------------------------------------------------------------------------- /components/toggle-theme.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import * as React from "react"; 4 | import { MoonIcon, SunIcon } from "@radix-ui/react-icons"; 5 | import { useTheme } from "next-themes"; 6 | 7 | import { Button } from "@/components/ui/button"; 8 | import { 9 | DropdownMenu, 10 | DropdownMenuContent, 11 | DropdownMenuItem, 12 | DropdownMenuTrigger, 13 | } from "@/components/ui/dropdown-menu"; 14 | 15 | export function ModeToggle() { 16 | const { setTheme } = useTheme(); 17 | 18 | return ( 19 | 20 | 21 | 26 | 27 | 28 | setTheme("light")}> 29 | Light 30 | 31 | setTheme("dark")}> 32 | Dark 33 | 34 | setTheme("system")}> 35 | System 36 | 37 | 38 | 39 | ); 40 | } 41 | -------------------------------------------------------------------------------- /app/settings/layout.tsx: -------------------------------------------------------------------------------- 1 | import { Metadata } from "next"; 2 | import { Separator } from "@/components/ui/separator"; 3 | import { SidebarNav } from "@/components/ui/sidebar-nav"; 4 | 5 | export const metadata: Metadata = { 6 | title: "Settings", 7 | description: "Stargazers settings page", 8 | }; 9 | 10 | const sidebarNavItems = [ 11 | { 12 | title: "Account", 13 | href: "/settings", 14 | }, 15 | { 16 | title: "Preferences", 17 | href: "/settings/preferences", 18 | }, 19 | { 20 | title: "IndexedDB", 21 | href: "/settings/indexdb", 22 | }, 23 | ]; 24 | 25 | interface SettingsLayoutProps { 26 | children: React.ReactNode; 27 | } 28 | 29 | export default function SettingsLayout({ children }: SettingsLayoutProps) { 30 | return ( 31 | <> 32 |
33 |
34 |

Settings

35 |

Manage your app settings.

36 |
37 | 38 |
39 | 42 |
{children}
43 |
44 |
45 | 46 | ); 47 | } 48 | -------------------------------------------------------------------------------- /components/ui/avatar.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import * as React from "react"; 4 | import * as AvatarPrimitive from "@radix-ui/react-avatar"; 5 | 6 | import { cn } from "@/lib/utils"; 7 | 8 | const Avatar = React.forwardRef< 9 | React.ElementRef, 10 | React.ComponentPropsWithoutRef 11 | >(({ className, ...props }, ref) => ( 12 | 20 | )); 21 | Avatar.displayName = AvatarPrimitive.Root.displayName; 22 | 23 | const AvatarImage = React.forwardRef< 24 | React.ElementRef, 25 | React.ComponentPropsWithoutRef 26 | >(({ className, ...props }, ref) => ( 27 | 32 | )); 33 | AvatarImage.displayName = AvatarPrimitive.Image.displayName; 34 | 35 | const AvatarFallback = React.forwardRef< 36 | React.ElementRef, 37 | React.ComponentPropsWithoutRef 38 | >(({ className, ...props }, ref) => ( 39 | 47 | )); 48 | AvatarFallback.displayName = AvatarPrimitive.Fallback.displayName; 49 | 50 | export { Avatar, AvatarImage, AvatarFallback }; 51 | -------------------------------------------------------------------------------- /components/date-picker.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import * as React from "react"; 4 | import { format } from "date-fns"; 5 | import { Calendar as CalendarIcon } from "lucide-react"; 6 | 7 | import { cn } from "@/lib/utils"; 8 | import { Button } from "@/components/ui/button"; 9 | import { Calendar } from "@/components/ui/calendar"; 10 | import { 11 | Popover, 12 | PopoverContent, 13 | PopoverTrigger, 14 | } from "@/components/ui/popover"; 15 | 16 | export default function DatePicker({ 17 | className, 18 | dateRange, 19 | setDateRange, 20 | }: any) { 21 | return ( 22 |
23 | 24 | 25 | 47 | 48 | 49 | 57 | 58 | 59 |
60 | ); 61 | } 62 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "stargazers", 3 | "version": "0.1.0", 4 | "private": true, 5 | "scripts": { 6 | "dev": "next dev -p 3007", 7 | "build": "next build", 8 | "start": "next start", 9 | "lint": "next lint" 10 | }, 11 | "dependencies": { 12 | "@octokit/graphql-schema": "^14.27.1", 13 | "@radix-ui/react-alert-dialog": "^1.0.4", 14 | "@radix-ui/react-avatar": "^1.0.3", 15 | "@radix-ui/react-dialog": "^1.0.4", 16 | "@radix-ui/react-dropdown-menu": "^2.0.5", 17 | "@radix-ui/react-hover-card": "^1.0.6", 18 | "@radix-ui/react-icons": "^1.3.0", 19 | "@radix-ui/react-popover": "^1.0.6", 20 | "@radix-ui/react-select": "^1.2.2", 21 | "@radix-ui/react-separator": "^1.0.3", 22 | "@radix-ui/react-slot": "^1.0.2", 23 | "@radix-ui/react-switch": "^1.0.3", 24 | "@radix-ui/react-toast": "^1.1.4", 25 | "@types/node": "20.4.9", 26 | "@types/react": "18.2.20", 27 | "@types/react-dom": "18.2.7", 28 | "autoprefixer": "10.4.14", 29 | "class-variance-authority": "^0.7.0", 30 | "clsx": "^2.0.0", 31 | "cmdk": "^0.2.0", 32 | "date-fns": "^2.30.0", 33 | "dayjs": "^1.11.9", 34 | "echarts": "^5.4.3", 35 | "eslint": "8.46.0", 36 | "eslint-config-next": "13.4.13", 37 | "framer-motion": "^10.15.2", 38 | "idb": "^7.1.1", 39 | "lodash": "^4.17.21", 40 | "lucide-react": "^0.268.0", 41 | "next": "13.4.19", 42 | "next-themes": "^0.2.1", 43 | "node-emoji": "^2.1.0", 44 | "postcss": "8.4.27", 45 | "react": "18.2.0", 46 | "react-confetti": "^6.1.0", 47 | "react-day-picker": "^8.8.1", 48 | "react-dom": "18.2.0", 49 | "react-use": "^17.4.0", 50 | "react-wrap-balancer": "^1.0.0", 51 | "sonner": "^0.6.2", 52 | "tailwind-merge": "^1.14.0", 53 | "tailwindcss": "3.3.3", 54 | "tailwindcss-animate": "^1.0.6", 55 | "typescript": "5.1.6", 56 | "zustand": "^4.4.1" 57 | }, 58 | "devDependencies": { 59 | "@types/lodash": "^4.14.197" 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /app/globals.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | 5 | @layer base { 6 | :root { 7 | --background: 0 0% 100%; 8 | --foreground: 222.2 84% 4.9%; 9 | 10 | --card: 0 0% 100%; 11 | --card-foreground: 222.2 84% 4.9%; 12 | 13 | --popover: 0 0% 100%; 14 | --popover-foreground: 222.2 84% 4.9%; 15 | 16 | --primary: 222.2 47.4% 11.2%; 17 | --primary-foreground: 210 40% 98%; 18 | 19 | --success: 142.1 76.2% 36.3%; 20 | --success-foreground: 355.7 100% 97.3%; 21 | 22 | --secondary: 210 40% 96.1%; 23 | --secondary-foreground: 222.2 47.4% 11.2%; 24 | 25 | --muted: 210 40% 96.1%; 26 | --muted-foreground: 215.4 16.3% 46.9%; 27 | 28 | --accent: 210 40% 96.1%; 29 | --accent-foreground: 222.2 47.4% 11.2%; 30 | 31 | --destructive: 0 84.2% 60.2%; 32 | --destructive-foreground: 210 40% 98%; 33 | 34 | --border: 214.3 31.8% 91.4%; 35 | --input: 214.3 31.8% 91.4%; 36 | --ring: 222.2 84% 4.9%; 37 | 38 | --radius: 0.5rem; 39 | } 40 | 41 | .dark { 42 | --background: 222.2 84% 4.9%; 43 | --foreground: 210 40% 98%; 44 | 45 | --card: 222.2 84% 4.9%; 46 | --card-foreground: 210 40% 98%; 47 | 48 | --popover: 222.2 84% 4.9%; 49 | --popover-foreground: 210 40% 98%; 50 | 51 | --primary: 210 40% 98%; 52 | --primary-foreground: 222.2 47.4% 11.2%; 53 | 54 | --secondary: 217.2 32.6% 17.5%; 55 | --secondary-foreground: 210 40% 98%; 56 | 57 | --muted: 217.2 32.6% 17.5%; 58 | --muted-foreground: 215 20.2% 65.1%; 59 | 60 | --accent: 217.2 32.6% 17.5%; 61 | --accent-foreground: 210 40% 98%; 62 | 63 | --destructive: 0 62.8% 30.6%; 64 | --destructive-foreground: 210 40% 98%; 65 | 66 | --border: 217.2 32.6% 17.5%; 67 | --input: 217.2 32.6% 17.5%; 68 | --ring: hsl(212.7, 26.8%, 83.9); 69 | } 70 | } 71 | 72 | @layer base { 73 | * { 74 | @apply border-border; 75 | } 76 | body { 77 | @apply bg-background text-foreground; 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # ⭐️ Stargazers 2 | 3 | Analyze and explore the stars of any GitHub user. 4 | 5 | ## Introduce 6 | 7 | [Stargazers](https://star.yuy1n.io) is a web application that allows you to add unlimited GitHub accounts and analyze and explore the star list of these accounts. 8 | 9 | ![CleanShot 2023-08-28 at 21.35.29@2x](https://cdn.jsdelivr.net/gh/yuyinws/static@master/2023/08/upgit_20230828_1693229767.png) 10 | 11 | ![CleanShot 2023-08-28 at 20.43.28@2x](https://cdn.jsdelivr.net/gh/yuyinws/static@master/2023/08/upgit_20230828_1693226665.png) 12 | 13 | ![CleanShot 2023-08-28 at 20.55.31@2x](https://cdn.jsdelivr.net/gh/yuyinws/static@master/2023/08/upgit_20230828_1693227392.png) 14 | 15 | ## Features 16 | 17 | - Supports adding unlimited GitHub accounts. 18 | - Supports adding accounts via various methods such as user search, GitHub OAuth, or one-click button (powered by [UserScript](#UserScript)). 19 | - Supports querying based on star time, programming languages, repository information and more. 20 | - Supports analyzing star lists. 21 | - Dark mode support. 22 | - Mobile end support. 23 | 24 | ## UserScript 25 | 26 | ![CleanShot 2023-08-28 at 21.15.21@2x](https://cdn.jsdelivr.net/gh/yuyinws/static@master/2023/08/upgit_20230828_1693228551.png) 27 | 28 | [Install from greasyfork](https://greasyfork.org/en/scripts/474055-add-to-stargazers) 29 | 30 | A UserScript that adds a button on the GitHub user profile page, allowing you to easily add it to stargazers. 31 | 32 | ## Troubleshooting 33 | All data is stored in the browser's IndexedDB. If you encounter any unexpected errors, you can try to delete the IndexedDB either from the [settings page](http://stargazers.dev/settings/indexdb) or manually (F12-Application-IndexedDB), then refresh the page. 34 | 35 | ## Build with 36 | 37 | [NextJS](https://nextjs.org/) 38 | 39 | [shadcn/ui](https://ui.shadcn.com/docs/installation/next) 40 | 41 | [IndexedDB](https://github.com/jakearchibald/idb) 42 | 43 | [GitHub GraphQL API](https://docs.github.com/en/graphql) 44 | 45 | [Vercel](https://vercel.com/) 46 | -------------------------------------------------------------------------------- /components/ui/button.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import { Slot } from "@radix-ui/react-slot"; 3 | import { cva, type VariantProps } from "class-variance-authority"; 4 | 5 | import { cn } from "@/lib/utils"; 6 | 7 | const buttonVariants = cva( 8 | "inline-flex items-center justify-center 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", 9 | { 10 | variants: { 11 | variant: { 12 | default: "bg-primary text-primary-foreground hover:bg-primary/90", 13 | destructive: 14 | "bg-destructive text-destructive-foreground hover:bg-destructive/90", 15 | outline: 16 | "border border-input bg-background hover:bg-accent hover:text-accent-foreground", 17 | secondary: 18 | "bg-secondary text-secondary-foreground hover:bg-secondary/80", 19 | ghost: "hover:bg-accent hover:text-accent-foreground", 20 | link: "text-primary underline-offset-4 hover:underline", 21 | }, 22 | size: { 23 | default: "h-10 px-4 py-2", 24 | sm: "h-9 rounded-md px-3", 25 | lg: "h-11 rounded-md px-8", 26 | icon: "h-10 w-10", 27 | }, 28 | }, 29 | defaultVariants: { 30 | variant: "default", 31 | size: "default", 32 | }, 33 | } 34 | ); 35 | 36 | export interface ButtonProps 37 | extends React.ButtonHTMLAttributes, 38 | VariantProps { 39 | asChild?: boolean; 40 | } 41 | 42 | const Button = React.forwardRef( 43 | ({ className, variant, size, asChild = false, ...props }, ref) => { 44 | const Comp = asChild ? Slot : "button"; 45 | return ( 46 | 51 | ); 52 | } 53 | ); 54 | Button.displayName = "Button"; 55 | 56 | export { Button, buttonVariants }; 57 | -------------------------------------------------------------------------------- /lib/analyze.ts: -------------------------------------------------------------------------------- 1 | import { QueryForm } from '@/store/star'; 2 | import { initDb, searchStar } from './db' 3 | import { getUnixTime } from 'date-fns'; 4 | import { cloneDeep } from 'lodash' 5 | 6 | export interface Language { 7 | name: string 8 | count: number 9 | color: string 10 | } 11 | 12 | export interface Owner { 13 | name: string 14 | count: number 15 | avatar: string 16 | } 17 | 18 | function replace(str: string) { 19 | return str.replace("-", '') 20 | } 21 | 22 | export async function analyze(login: string, searchQueryForm: QueryForm) { 23 | const db = await initDb() 24 | const _searchQueryForm = cloneDeep(searchQueryForm) 25 | _searchQueryForm.startTime = _searchQueryForm.startTime ? getUnixTime(_searchQueryForm.startTime) : -Infinity 26 | _searchQueryForm.endTime = _searchQueryForm.endTime ? getUnixTime(_searchQueryForm.endTime) : Infinity 27 | 28 | const results = await searchStar(db, login, { 29 | ..._searchQueryForm, 30 | page: 1, 31 | size: 0 32 | }) 33 | 34 | const owners: Owner[] = [] 35 | 36 | const languages: Language[] = [] 37 | 38 | results.stars.forEach((star) => { 39 | const findOwnerIndex = owners.findIndex(owner => replace(owner.name) === replace(star.owner)) 40 | const findLangIndex = languages.findIndex(lang => lang.name === star.language) 41 | if (findOwnerIndex === -1) { 42 | owners.push({ 43 | name: replace(star.owner), 44 | count: 1, 45 | avatar: star.ownerAvatarUrl 46 | }) 47 | } else { 48 | owners[findOwnerIndex].count++ 49 | } 50 | 51 | if (findLangIndex === -1) { 52 | languages.push({ 53 | name: star.language, 54 | count: 1, 55 | color: star.languageColor 56 | }) 57 | } else { 58 | languages[findLangIndex].count++ 59 | } 60 | }) 61 | 62 | owners.sort((a, b) => { 63 | return b.count - a.count 64 | }) 65 | 66 | languages.sort((a, b) => { 67 | return b.count - a.count 68 | }) 69 | 70 | return { 71 | owners: owners.slice(0, 5), 72 | languages: languages.slice(0, 5) 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /components/ui/card.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | 3 | import { cn } from "@/lib/utils" 4 | 5 | const Card = React.forwardRef< 6 | HTMLDivElement, 7 | React.HTMLAttributes 8 | >(({ className, ...props }, ref) => ( 9 |
17 | )) 18 | Card.displayName = "Card" 19 | 20 | const CardHeader = React.forwardRef< 21 | HTMLDivElement, 22 | React.HTMLAttributes 23 | >(({ className, ...props }, ref) => ( 24 |
29 | )) 30 | CardHeader.displayName = "CardHeader" 31 | 32 | const CardTitle = React.forwardRef< 33 | HTMLParagraphElement, 34 | React.HTMLAttributes 35 | >(({ className, ...props }, ref) => ( 36 |

44 | )) 45 | CardTitle.displayName = "CardTitle" 46 | 47 | const CardDescription = React.forwardRef< 48 | HTMLParagraphElement, 49 | React.HTMLAttributes 50 | >(({ className, ...props }, ref) => ( 51 |

56 | )) 57 | CardDescription.displayName = "CardDescription" 58 | 59 | const CardContent = React.forwardRef< 60 | HTMLDivElement, 61 | React.HTMLAttributes 62 | >(({ className, ...props }, ref) => ( 63 |

64 | )) 65 | CardContent.displayName = "CardContent" 66 | 67 | const CardFooter = React.forwardRef< 68 | HTMLDivElement, 69 | React.HTMLAttributes 70 | >(({ className, ...props }, ref) => ( 71 |
76 | )) 77 | CardFooter.displayName = "CardFooter" 78 | 79 | export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent } 80 | -------------------------------------------------------------------------------- /resource/add-to-stargazers.user.js: -------------------------------------------------------------------------------- 1 | // ==UserScript== 2 | // @name add-to-stargazers 3 | // @namespace http://tampermonkey.net/ 4 | // @version 0.2 5 | // @description One click add GitHub user to Stargazers 6 | // @author yuyinws 7 | // @match https://github.com/* 8 | // @icon https://stargazers.dev/favicon.ico 9 | // @grant window.onurlchange 10 | // @run-at document-end 11 | // @license MIT 12 | // ==/UserScript== 13 | 14 | 15 | (function() { 16 | 'use strict'; 17 | var _wr = function(type) { 18 | var orig = history[type]; 19 | return function() { 20 | var rv = orig.apply(this, arguments); 21 | var e = new Event(type); 22 | e.arguments = arguments; 23 | window.dispatchEvent(e); 24 | return rv; 25 | }; 26 | }; 27 | history.pushState = _wr('pushState'); 28 | history.replaceState = _wr('replaceState'); 29 | 30 | function addBtn() { 31 | const parent = document.querySelector('.js-profile-editable-area')?.parentNode 32 | const isBtnExist = document.querySelector('#add-to-stargazers') 33 | 34 | if (parent && !isBtnExist) { 35 | const title = document.title.replace(" ", "") 36 | const regex = /(.*?)\((.*?)\)/; 37 | const match = title.match(regex); 38 | 39 | const avatar_url = document.querySelector(".avatar-user")?.getAttribute('src') 40 | 41 | const userInfo = { 42 | login: match[1], 43 | name: match[2], 44 | avatar_url, 45 | } 46 | 47 | const encode = btoa(encodeURIComponent(JSON.stringify(userInfo))) 48 | 49 | 50 | const reference = document.querySelector('.js-profile-editable-area') 51 | const insert = document.createElement('a') 52 | insert.id = 'add-to-stargazers' 53 | insert.innerHTML = 'Add to Stargazers' 54 | insert.className = 'btn btn-block mb-md-3' 55 | insert.target = '_blank' 56 | insert.href = "https://stargazers.dev/login?encode=" + encode 57 | parent.insertBefore(insert, reference) 58 | } 59 | 60 | } 61 | 62 | window.addEventListener('replaceState', function() { 63 | addBtn() 64 | }); 65 | window.addEventListener('pushState', function() { 66 | addBtn() 67 | }); 68 | 69 | 70 | })(); 71 | -------------------------------------------------------------------------------- /store/account.ts: -------------------------------------------------------------------------------- 1 | import { create } from 'zustand' 2 | import { Account, initDb, getAllAccount } from "@/lib/db"; 3 | import { persist } from 'zustand/middleware' 4 | 5 | interface AccountStore { 6 | currentAccount: Account | null 7 | allAccount: Account[] 8 | setCurrentAccount: (account: Account | null) => void 9 | setAllAccount: (accounts: Account[]) => void 10 | refreshAllAccount: () => Promise 11 | deleteAccount: (account: Account) => Promise 12 | } 13 | 14 | export const useAccountStore = create()( 15 | persist( 16 | (set, get) => ({ 17 | currentAccount: null, 18 | allAccount: [], 19 | 20 | setCurrentAccount: (account: Account | null) => { 21 | set(() => ({ 22 | currentAccount: account 23 | })) 24 | }, 25 | 26 | refreshAllAccount: async () => { 27 | const db = await initDb(); 28 | const accounts = await getAllAccount(db); 29 | 30 | const currentAccount = accounts?.find((account) => account.login === get().currentAccount?.login) 31 | 32 | set(() => ({ 33 | allAccount: accounts, 34 | currentAccount 35 | })) 36 | }, 37 | 38 | setAllAccount: (accounts: Account[]) => { 39 | set(() => ({ 40 | allAccount: accounts 41 | })) 42 | }, 43 | 44 | deleteAccount: async (account: Account) => { 45 | try { 46 | const db = await initDb(); 47 | await db.delete('accounts', account.login) 48 | 49 | const stars = await db.getAllFromIndex('stars', 'by_login', account.login) 50 | 51 | for (const star of stars) { 52 | await db.delete('stars', star.id) 53 | } 54 | 55 | const accounts = await getAllAccount(db); 56 | 57 | set(() => ({ 58 | allAccount: accounts || [] 59 | })) 60 | 61 | if (get().currentAccount?.login === account.login && accounts?.length > 0) { 62 | set(() => ({ 63 | currentAccount: accounts[0] 64 | })) 65 | } else if (accounts?.length === 0) { 66 | set(() => ({ 67 | currentAccount: null 68 | })) 69 | } 70 | } catch (error) { 71 | throw error 72 | } 73 | } 74 | }), 75 | { 76 | name: "account", 77 | } 78 | ) 79 | ) 80 | -------------------------------------------------------------------------------- /tailwind.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('tailwindcss').Config} */ 2 | module.exports = { 3 | darkMode: ["class"], 4 | content: [ 5 | './pages/**/*.{ts,tsx}', 6 | './components/**/*.{ts,tsx}', 7 | './app/**/*.{ts,tsx}', 8 | './src/**/*.{ts,tsx}', 9 | ], 10 | theme: { 11 | container: { 12 | center: true, 13 | padding: "2rem", 14 | screens: { 15 | "2xl": "1400px", 16 | }, 17 | }, 18 | extend: { 19 | colors: { 20 | border: "hsl(var(--border))", 21 | input: "hsl(var(--input))", 22 | ring: "hsl(var(--ring))", 23 | background: "hsl(var(--background))", 24 | foreground: "hsl(var(--foreground))", 25 | primary: { 26 | DEFAULT: "hsl(var(--primary))", 27 | foreground: "hsl(var(--primary-foreground))", 28 | }, 29 | secondary: { 30 | DEFAULT: "hsl(var(--secondary))", 31 | foreground: "hsl(var(--secondary-foreground))", 32 | }, 33 | destructive: { 34 | DEFAULT: "hsl(var(--destructive))", 35 | foreground: "hsl(var(--destructive-foreground))", 36 | }, 37 | success: { 38 | DEFAULT: "hsl(var(--success))", 39 | foreground: "hsl(var(--success-foreground))", 40 | }, 41 | muted: { 42 | DEFAULT: "hsl(var(--muted))", 43 | foreground: "hsl(var(--muted-foreground))", 44 | }, 45 | accent: { 46 | DEFAULT: "hsl(var(--accent))", 47 | foreground: "hsl(var(--accent-foreground))", 48 | }, 49 | popover: { 50 | DEFAULT: "hsl(var(--popover))", 51 | foreground: "hsl(var(--popover-foreground))", 52 | }, 53 | card: { 54 | DEFAULT: "hsl(var(--card))", 55 | foreground: "hsl(var(--card-foreground))", 56 | }, 57 | }, 58 | borderRadius: { 59 | lg: "var(--radius)", 60 | md: "calc(var(--radius) - 2px)", 61 | sm: "calc(var(--radius) - 4px)", 62 | }, 63 | keyframes: { 64 | "accordion-down": { 65 | from: { height: 0 }, 66 | to: { height: "var(--radix-accordion-content-height)" }, 67 | }, 68 | "accordion-up": { 69 | from: { height: "var(--radix-accordion-content-height)" }, 70 | to: { height: 0 }, 71 | }, 72 | }, 73 | animation: { 74 | "accordion-down": "accordion-down 0.2s ease-out", 75 | "accordion-up": "accordion-up 0.2s ease-out", 76 | }, 77 | }, 78 | }, 79 | plugins: [require("tailwindcss-animate")], 80 | } 81 | -------------------------------------------------------------------------------- /components/ui/calendar.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import * as React from "react" 4 | import { ChevronLeft, ChevronRight } from "lucide-react" 5 | import { DayPicker } from "react-day-picker" 6 | 7 | import { cn } from "@/lib/utils" 8 | import { buttonVariants } from "@/components/ui/button" 9 | 10 | export type CalendarProps = React.ComponentProps 11 | 12 | function Calendar({ 13 | className, 14 | classNames, 15 | showOutsideDays = true, 16 | ...props 17 | }: CalendarProps) { 18 | return ( 19 | , 56 | IconRight: ({ ...props }) => , 57 | }} 58 | {...props} 59 | /> 60 | ) 61 | } 62 | Calendar.displayName = "Calendar" 63 | 64 | export { Calendar } 65 | -------------------------------------------------------------------------------- /app/settings/preferences/page.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { Separator } from "@/components/ui/separator"; 4 | import { Switch } from "@/components/ui/switch"; 5 | import { useStore, useSettingStore, useStarStore } from "@/store"; 6 | import { 7 | Select, 8 | SelectContent, 9 | SelectGroup, 10 | SelectItem, 11 | SelectTrigger, 12 | SelectValue, 13 | } from "@/components/ui/select"; 14 | import { toast } from "sonner"; 15 | 16 | export default function Settings() { 17 | const settingStore = useStore(useSettingStore, (state) => state)!; 18 | 19 | return ( 20 |
21 |
22 |

Preferences

23 |

Manage preferences.

24 |
25 | 26 | 27 |
28 |
29 |
Auto switch account
30 |
31 | Automatically switch to the new account after adding a new one. 32 |
33 |
34 | 35 | { 38 | settingStore?.setSettings({ 39 | autoSwitch: event, 40 | }); 41 | toast.success("Setting updated"); 42 | }} 43 | > 44 |
45 | 46 |
47 |
48 |
Date range
49 |
50 | Default time range for querying. 51 |
52 |
53 | 54 | 79 |
80 |
81 | ); 82 | } 83 | -------------------------------------------------------------------------------- /app/page.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import RepoList from "@/components/repo-list"; 4 | import Search from "@/components/search"; 5 | import Pagination from "@/components/pagination"; 6 | import { 7 | useStore, 8 | useStarStore, 9 | useAccountStore, 10 | useSettingStore, 11 | startTimeMap, 12 | } from "@/store"; 13 | import { useRouter } from "next/navigation"; 14 | import { useEffect } from "react"; 15 | import { Skeleton } from "@/components/ui/skeleton"; 16 | 17 | export default function Home() { 18 | const starStore = useStarStore(); 19 | const accountStore = useAccountStore(); 20 | const settingStore = useSettingStore(); 21 | const router = useRouter(); 22 | 23 | async function getAccount() { 24 | if (accountStore.currentAccount) { 25 | const dateRange = settingStore.settings.dateRange; 26 | starStore.setQueryForm({ 27 | startTimeId: dateRange, 28 | }); 29 | starStore.setQueryForm({ 30 | startTime: startTimeMap[dateRange], 31 | }); 32 | 33 | starStore.syncSearchQueryForm(); 34 | if (accountStore.currentAccount.lastSyncAt) { 35 | await starStore.getStarFromIndexDB(accountStore!.currentAccount!.login); 36 | } else { 37 | await starStore.fetchStars(accountStore.currentAccount.login); 38 | await accountStore.refreshAllAccount(); 39 | } 40 | } else { 41 | router.replace("/login"); 42 | } 43 | } 44 | 45 | useEffect(() => { 46 | if (accountStore) { 47 | getAccount(); 48 | } 49 | }, []); 50 | 51 | return ( 52 |
53 | {!starStore || starStore?.loading ? ( 54 | <> 55 | 56 |
57 | {Array(12) 58 | .fill(0) 59 | .map((_, index) => { 60 | return ( 61 |
62 |
63 |
64 |
65 |
66 | 67 | 68 |
69 | 70 | 71 |
72 |
73 |
74 |
75 | ); 76 | })} 77 |
78 | 79 | 80 | ) : ( 81 | <> 82 | 83 | 84 | 85 | 86 | )} 87 |
88 | ); 89 | } 90 | -------------------------------------------------------------------------------- /components/ui/table.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | 3 | import { cn } from "@/lib/utils" 4 | 5 | const Table = React.forwardRef< 6 | HTMLTableElement, 7 | React.HTMLAttributes 8 | >(({ className, ...props }, ref) => ( 9 |
10 | 15 | 16 | )) 17 | Table.displayName = "Table" 18 | 19 | const TableHeader = React.forwardRef< 20 | HTMLTableSectionElement, 21 | React.HTMLAttributes 22 | >(({ className, ...props }, ref) => ( 23 | 24 | )) 25 | TableHeader.displayName = "TableHeader" 26 | 27 | const TableBody = React.forwardRef< 28 | HTMLTableSectionElement, 29 | React.HTMLAttributes 30 | >(({ className, ...props }, ref) => ( 31 | 36 | )) 37 | TableBody.displayName = "TableBody" 38 | 39 | const TableFooter = React.forwardRef< 40 | HTMLTableSectionElement, 41 | React.HTMLAttributes 42 | >(({ className, ...props }, ref) => ( 43 | 48 | )) 49 | TableFooter.displayName = "TableFooter" 50 | 51 | const TableRow = React.forwardRef< 52 | HTMLTableRowElement, 53 | React.HTMLAttributes 54 | >(({ className, ...props }, ref) => ( 55 | 63 | )) 64 | TableRow.displayName = "TableRow" 65 | 66 | const TableHead = React.forwardRef< 67 | HTMLTableCellElement, 68 | React.ThHTMLAttributes 69 | >(({ className, ...props }, ref) => ( 70 |
78 | )) 79 | TableHead.displayName = "TableHead" 80 | 81 | const TableCell = React.forwardRef< 82 | HTMLTableCellElement, 83 | React.TdHTMLAttributes 84 | >(({ className, ...props }, ref) => ( 85 | 90 | )) 91 | TableCell.displayName = "TableCell" 92 | 93 | const TableCaption = React.forwardRef< 94 | HTMLTableCaptionElement, 95 | React.HTMLAttributes 96 | >(({ className, ...props }, ref) => ( 97 |
102 | )) 103 | TableCaption.displayName = "TableCaption" 104 | 105 | export { 106 | Table, 107 | TableHeader, 108 | TableBody, 109 | TableFooter, 110 | TableHead, 111 | TableRow, 112 | TableCell, 113 | TableCaption, 114 | } 115 | -------------------------------------------------------------------------------- /app/login/page.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import UserSearch from "@/components/user-search"; 4 | import { initDb, getAllAccount, addAccount } from "@/lib/db"; 5 | import { useRouter, useSearchParams } from "next/navigation"; 6 | import { useEffect } from "react"; 7 | import { toast } from "sonner"; 8 | import { useAccountStore, useSettingStore } from "@/store"; 9 | import styles from "@/styles/login.module.css"; 10 | import Balancer from "react-wrap-balancer"; 11 | 12 | export default function Login() { 13 | const router = useRouter(); 14 | const searchParams = useSearchParams(); 15 | 16 | const accountStore = useAccountStore(); 17 | const settingStore = useSettingStore(); 18 | 19 | const access_token = searchParams.get("access_token"); 20 | const encode = searchParams.get("encode"); 21 | async function getAccount() { 22 | const db = await initDb(); 23 | let addedUserLogin = ""; 24 | 25 | if (access_token) { 26 | const response = await fetch("/api/gh/user?access_token=" + access_token); 27 | const { user } = await response.json(); 28 | try { 29 | await addAccount(db, { 30 | login: user.login, 31 | name: user.name, 32 | avatarUrl: user.avatar_url, 33 | from: "github", 34 | lastSyncAt: "", 35 | addedAt: new Date().toISOString(), 36 | }); 37 | 38 | addedUserLogin = user.login; 39 | 40 | toast.success("Account added"); 41 | } catch (error) { 42 | toast.error("Error adding account", { 43 | description: String(error), 44 | }); 45 | } 46 | } else if (encode) { 47 | try { 48 | const decoded = decodeURIComponent(atob(encode)); 49 | const user = JSON.parse(decoded); 50 | 51 | await addAccount(db, { 52 | login: user.login, 53 | name: user.name, 54 | avatarUrl: user.avatar_url, 55 | from: "github", 56 | lastSyncAt: "", 57 | addedAt: new Date().toISOString(), 58 | }); 59 | 60 | addedUserLogin = user.login; 61 | 62 | toast.success("Account added"); 63 | } catch (error) { 64 | toast.error("Error adding account", { 65 | description: String(error), 66 | }); 67 | } 68 | } 69 | 70 | const accounts = await getAllAccount(db); 71 | if (accounts?.length > 0) { 72 | accountStore?.setAllAccount(accounts); 73 | 74 | if (settingStore.settings.autoSwitch && addedUserLogin) { 75 | const findAccount = accounts.find( 76 | (account) => account.login === addedUserLogin 77 | ); 78 | if (findAccount) accountStore?.setCurrentAccount(findAccount); 79 | } 80 | 81 | if (!accountStore?.currentAccount) { 82 | accountStore?.setCurrentAccount(accounts[0]); 83 | } 84 | router.replace("/"); 85 | } 86 | } 87 | 88 | useEffect(() => { 89 | getAccount(); 90 | }, []); 91 | 92 | return ( 93 | <> 94 |
95 |

98 | Stargazers 99 |

100 |

103 | Analyze and explore the stars of any GitHub user. 104 |

105 | 106 |
107 | 108 | ); 109 | } 110 | -------------------------------------------------------------------------------- /app/api/gh/stars/[...]/route.ts: -------------------------------------------------------------------------------- 1 | import { NextResponse } from 'next/server' 2 | import dayjs from 'dayjs' 3 | 4 | const query = `#graphql 5 | query GetStarredRepositories($username: String!, $cursor: String) { 6 | user(login: $username) { 7 | starredRepositories(first: 100, after: $cursor, orderBy: {direction: DESC, field: STARRED_AT}) { 8 | pageInfo { 9 | hasNextPage 10 | endCursor 11 | } 12 | edges { 13 | starredAt 14 | node { 15 | name 16 | nameWithOwner 17 | updatedAt 18 | owner { 19 | avatarUrl 20 | login 21 | } 22 | url 23 | homepageUrl 24 | description 25 | forkCount 26 | stargazerCount 27 | primaryLanguage { 28 | name 29 | color 30 | } 31 | allowUpdateBranch 32 | isArchived 33 | isTemplate 34 | licenseInfo { 35 | name 36 | } 37 | } 38 | } 39 | } 40 | } 41 | } 42 | `; 43 | 44 | export const runtime = 'edge'; 45 | 46 | export async function GET(request: Request) { 47 | try { 48 | const { pathname } = new URL(request.url) 49 | const username = pathname.split('/')[4] 50 | const cursor = pathname.split('/')[5] || '' 51 | if (!username) { 52 | return NextResponse.json({ errors: 'username is required' }, { status: 500 }) 53 | } 54 | const res = await fetch('https://api.github.com/graphql', { 55 | method: 'POST', 56 | headers: { 57 | 'Content-Type': 'application/json', 58 | 'Accept': 'application/json', 59 | 'Authorization': `Bearer ${process.env.GITHUB_ACCESS_TOKEN}` 60 | }, 61 | body: JSON.stringify({ 62 | query, 63 | variables: { 64 | username, 65 | cursor, 66 | }, 67 | }) 68 | }) 69 | 70 | const data = await res.json() 71 | 72 | if (data?.errors) { 73 | return NextResponse.json({ errors: data.errors }, { status: 500 }) 74 | } 75 | 76 | const formatedStars = data?.data?.user?.starredRepositories?.edges.map((edge: any) => { 77 | return { 78 | id: username + edge.node.owner?.login + edge.node.name, 79 | login: username, 80 | repo: edge.node?.name, 81 | forkCount: edge.node?.forkCount, 82 | description: edge.node?.description, 83 | homepageUrl: edge.node?.homepageUrl, 84 | isArchived: edge.node?.isArchived, 85 | isTemplate: edge.node?.isTemplate, 86 | license: edge.node.licenseInfo?.name, 87 | owner: edge.node.owner?.login, 88 | ownerAvatarUrl: edge.node.owner.avatarUrl, 89 | language: edge.node.primaryLanguage?.name, 90 | languageColor: edge.node.primaryLanguage?.color, 91 | stargazerCount: edge.node?.stargazerCount, 92 | pushedAt: edge.node?.pushedAt, 93 | updatedAt: edge.node?.updatedAt, 94 | starAt: dayjs(edge?.starredAt).unix() 95 | } 96 | }) 97 | 98 | const pageInfo = data?.data?.user?.starredRepositories?.pageInfo 99 | 100 | return new Response(JSON.stringify({ 101 | state: 'success', 102 | data: { 103 | stars: formatedStars, 104 | pageInfo 105 | }, 106 | }), 107 | { 108 | status: 200, 109 | headers: { 110 | 'Cache-Control': 'public, s-maxage=3600', 111 | 'CDN-Cache-Control': 'public, s-maxage=3600', 112 | 'Vercel-CDN-Cache-Control': 'public, s-maxage=3600', 113 | }, 114 | }) 115 | } catch (error) { 116 | console.log(error) 117 | return NextResponse.json( 118 | { error: error }, 119 | { status: 500 } 120 | ) 121 | } 122 | } 123 | -------------------------------------------------------------------------------- /lib/db.ts: -------------------------------------------------------------------------------- 1 | import { QueryForm } from '@/store/star' 2 | import { openDB, DBSchema, IDBPDatabase } from 'idb' 3 | 4 | export interface Star { 5 | id: string 6 | login: string 7 | repo: string 8 | forkCount: number 9 | description: string 10 | homepageUrl: string 11 | isArchived: boolean 12 | isTemplate: boolean 13 | license: string 14 | owner: string 15 | ownerAvatarUrl: string 16 | language: string 17 | languageColor: string 18 | stargazerCount: string 19 | updatedAt: string 20 | starAt: number 21 | } 22 | 23 | export interface Account { 24 | login: string 25 | avatarUrl: string 26 | name: string 27 | from: 'github' | 'search' 28 | lastSyncAt: string 29 | addedAt: string 30 | } 31 | 32 | export interface DB extends DBSchema { 33 | stars: { 34 | value: Star 35 | key: string 36 | indexes: { 37 | "by_starAt": string 38 | "by_repo": string 39 | "by_login": string 40 | } 41 | }, 42 | accounts: { 43 | value: Account 44 | key: string 45 | indexes: { 46 | "by_login": string 47 | } 48 | }, 49 | 50 | } 51 | 52 | export async function initDb() { 53 | const db = await openDB('Stargazers', 1, { 54 | upgrade(db) { 55 | const starStore = db.createObjectStore('stars', { 56 | keyPath: 'id', 57 | autoIncrement: false, 58 | }); 59 | 60 | starStore.createIndex('by_repo', 'repo', { unique: false }); 61 | starStore.createIndex('by_starAt', 'starAt', { unique: false }); 62 | starStore.createIndex('by_login', 'login', { unique: false }); 63 | 64 | const accountStore = db.createObjectStore('accounts', { 65 | keyPath: 'login', 66 | }) 67 | accountStore.createIndex('by_login', 'login', { unique: false }); 68 | }, 69 | }); 70 | 71 | return db; 72 | } 73 | 74 | export async function addStar(db: IDBPDatabase, star: Star) { 75 | try { 76 | const isExist = await db.get('stars', star.id) 77 | if (isExist) { 78 | db.put('stars', star); 79 | } else { 80 | db.add('stars', star); 81 | } 82 | 83 | } catch (error) { 84 | console.log(error) 85 | } 86 | } 87 | 88 | export async function searchByRepo(db: IDBPDatabase, repo: string) { 89 | return db.getAllFromIndex('stars', 'by_repo', repo); 90 | } 91 | 92 | export async function searchByStarAt(db: IDBPDatabase, start: string, end: string) { 93 | return db.getAllFromIndex('stars', 'by_starAt', IDBKeyRange.bound(start, end)); 94 | } 95 | 96 | export async function searchStar(db: IDBPDatabase, login: string, queryForm: QueryForm & { page: number, size: number }) { 97 | const { startTime, endTime, page, size, keyword,language } = queryForm 98 | let lowerKeyword = keyword?.toLowerCase() 99 | let lowerLanguage = language?.toLowerCase() 100 | const stars = await db.getAllFromIndex('stars', 'by_starAt', IDBKeyRange.bound(startTime, endTime)); 101 | 102 | stars.reverse() 103 | 104 | const results = stars 105 | .filter((star) => star.login === login) 106 | .filter(star => { 107 | return !keyword 108 | || star.repo?.toLowerCase().includes(lowerKeyword) 109 | || star.description?.toLowerCase().includes(lowerKeyword) 110 | || star.owner?.toLowerCase().includes(lowerKeyword) 111 | }).filter(star => { 112 | return star.language?.toLowerCase().includes(lowerLanguage) 113 | }) 114 | 115 | return { 116 | stars: size === 0 ? results : results.slice((page - 1) * size, page * size), 117 | total: results.length 118 | } 119 | 120 | } 121 | 122 | export async function addAccount(db: IDBPDatabase, account: Account) { 123 | const tx = db.transaction('accounts', 'readwrite') 124 | await tx.store.add(account) 125 | await tx.done 126 | return tx 127 | } 128 | 129 | export async function getAllAccount(db: IDBPDatabase) { 130 | const transaction = db.transaction('accounts', 'readonly') 131 | const store = transaction.objectStore('accounts') 132 | const accounts = await store.getAll() 133 | 134 | accounts.sort((a, b) => { 135 | return Date.parse(a.addedAt) - Date.parse(b.addedAt) 136 | }) 137 | 138 | return accounts 139 | } 140 | -------------------------------------------------------------------------------- /components/search.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import DatePicker from "./date-picker"; 4 | import { Button } from "@/components/ui/button"; 5 | import { subMonths } from "date-fns"; 6 | import { DateRange } from "react-day-picker"; 7 | import { Input } from "@/components/ui/input"; 8 | import { useStore, useStarStore, useAccountStore, startTimeMap } from "@/store"; 9 | import { 10 | Select, 11 | SelectContent, 12 | SelectGroup, 13 | SelectItem, 14 | SelectTrigger, 15 | SelectValue, 16 | } from "@/components/ui/select"; 17 | import { useKeyboardShortcut } from "@/lib/useKeyboardShortcut"; 18 | import Analyze from "@/components/analyze"; 19 | 20 | export default function Search() { 21 | const starStore = useStore(useStarStore, (state) => state)!; 22 | const accountStore = useStore(useAccountStore, (state) => state)!; 23 | 24 | function handleSearch() { 25 | starStore.syncSearchQueryForm(); 26 | starStore.setPagintion({ 27 | page: 1, 28 | }); 29 | starStore.getStarFromIndexDB(accountStore.currentAccount?.login!); 30 | } 31 | 32 | function handleReset() { 33 | // setPicker("2"); 34 | starStore.setQueryForm({ 35 | startTime: subMonths(new Date(), 12), 36 | endTime: new Date(), 37 | keyword: "", 38 | language: "", 39 | }); 40 | starStore.syncSearchQueryForm(); 41 | starStore.setPagintion({ 42 | page: 1, 43 | size: 12, 44 | }); 45 | starStore.getStarFromIndexDB(accountStore.currentAccount?.login!); 46 | } 47 | 48 | useKeyboardShortcut(["enter"], handleSearch); 49 | 50 | return starStore ? ( 51 |
52 |
53 | 82 | { 88 | if (event) { 89 | starStore.setQueryForm({ 90 | startTime: event.from, 91 | endTime: event.to, 92 | }); 93 | } 94 | }} 95 | > 96 | { 99 | starStore.setQueryForm({ 100 | keyword: e.target.value, 101 | }); 102 | }} 103 | className="w-[22rem] xl:w-[15rem] 2xl:w-[20rem]" 104 | placeholder="search by owner, repo name, description ..." 105 | > 106 | { 109 | starStore.setQueryForm({ 110 | language: e.target.value, 111 | }); 112 | }} 113 | className="w-[22rem] xl:w-[15rem] 2xl:w-[20rem]" 114 | placeholder="search by language" 115 | > 116 |
117 |
118 | 119 | 122 | 123 |
124 |
125 | ) : ( 126 | "" 127 | ); 128 | } 129 | -------------------------------------------------------------------------------- /components/ui/dialog.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import * as React from "react"; 4 | import * as DialogPrimitive from "@radix-ui/react-dialog"; 5 | import { Cross1Icon } from "@radix-ui/react-icons"; 6 | 7 | import { cn } from "@/lib/utils"; 8 | 9 | const Dialog = DialogPrimitive.Root; 10 | 11 | const DialogTrigger = DialogPrimitive.Trigger; 12 | 13 | const DialogPortal = ({ 14 | className, 15 | ...props 16 | }: DialogPrimitive.DialogPortalProps) => ( 17 | 18 | ); 19 | DialogPortal.displayName = DialogPrimitive.Portal.displayName; 20 | 21 | const DialogOverlay = React.forwardRef< 22 | React.ElementRef, 23 | React.ComponentPropsWithoutRef 24 | >(({ className, ...props }, ref) => ( 25 | 33 | )); 34 | DialogOverlay.displayName = DialogPrimitive.Overlay.displayName; 35 | 36 | const DialogContent = React.forwardRef< 37 | React.ElementRef, 38 | React.ComponentPropsWithoutRef 39 | >(({ className, children, ...props }, ref) => ( 40 | 41 | 42 | 50 | {children} 51 | 52 | 53 | Close 54 | 55 | 56 | 57 | )); 58 | DialogContent.displayName = DialogPrimitive.Content.displayName; 59 | 60 | const DialogHeader = ({ 61 | className, 62 | ...props 63 | }: React.HTMLAttributes) => ( 64 |
71 | ); 72 | DialogHeader.displayName = "DialogHeader"; 73 | 74 | const DialogFooter = ({ 75 | className, 76 | ...props 77 | }: React.HTMLAttributes) => ( 78 |
85 | ); 86 | DialogFooter.displayName = "DialogFooter"; 87 | 88 | const DialogTitle = React.forwardRef< 89 | React.ElementRef, 90 | React.ComponentPropsWithoutRef 91 | >(({ className, ...props }, ref) => ( 92 | 100 | )); 101 | DialogTitle.displayName = DialogPrimitive.Title.displayName; 102 | 103 | const DialogDescription = React.forwardRef< 104 | React.ElementRef, 105 | React.ComponentPropsWithoutRef 106 | >(({ className, ...props }, ref) => ( 107 | 112 | )); 113 | DialogDescription.displayName = DialogPrimitive.Description.displayName; 114 | 115 | export { 116 | Dialog, 117 | DialogTrigger, 118 | DialogContent, 119 | DialogHeader, 120 | DialogFooter, 121 | DialogTitle, 122 | DialogDescription, 123 | }; 124 | -------------------------------------------------------------------------------- /components/ui/select.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import * as React from "react" 4 | import * as SelectPrimitive from "@radix-ui/react-select" 5 | import { Check, ChevronDown } from "lucide-react" 6 | 7 | import { cn } from "@/lib/utils" 8 | 9 | const Select = SelectPrimitive.Root 10 | 11 | const SelectGroup = SelectPrimitive.Group 12 | 13 | const SelectValue = SelectPrimitive.Value 14 | 15 | const SelectTrigger = React.forwardRef< 16 | React.ElementRef, 17 | React.ComponentPropsWithoutRef 18 | >(({ className, children, ...props }, ref) => ( 19 | 27 | {children} 28 | 29 | 30 | 31 | 32 | )) 33 | SelectTrigger.displayName = SelectPrimitive.Trigger.displayName 34 | 35 | const SelectContent = React.forwardRef< 36 | React.ElementRef, 37 | React.ComponentPropsWithoutRef 38 | >(({ className, children, position = "popper", ...props }, ref) => ( 39 | 40 | 51 | 58 | {children} 59 | 60 | 61 | 62 | )) 63 | SelectContent.displayName = SelectPrimitive.Content.displayName 64 | 65 | const SelectLabel = React.forwardRef< 66 | React.ElementRef, 67 | React.ComponentPropsWithoutRef 68 | >(({ className, ...props }, ref) => ( 69 | 74 | )) 75 | SelectLabel.displayName = SelectPrimitive.Label.displayName 76 | 77 | const SelectItem = React.forwardRef< 78 | React.ElementRef, 79 | React.ComponentPropsWithoutRef 80 | >(({ className, children, ...props }, ref) => ( 81 | 89 | 90 | 91 | 92 | 93 | 94 | 95 | {children} 96 | 97 | )) 98 | SelectItem.displayName = SelectPrimitive.Item.displayName 99 | 100 | const SelectSeparator = React.forwardRef< 101 | React.ElementRef, 102 | React.ComponentPropsWithoutRef 103 | >(({ className, ...props }, ref) => ( 104 | 109 | )) 110 | SelectSeparator.displayName = SelectPrimitive.Separator.displayName 111 | 112 | export { 113 | Select, 114 | SelectGroup, 115 | SelectValue, 116 | SelectTrigger, 117 | SelectContent, 118 | SelectLabel, 119 | SelectItem, 120 | SelectSeparator, 121 | } 122 | -------------------------------------------------------------------------------- /app/settings/indexdb/page.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { 4 | Table, 5 | TableBody, 6 | TableCell, 7 | TableHead, 8 | TableHeader, 9 | TableRow, 10 | } from "@/components/ui/table"; 11 | import { useState, useEffect } from "react"; 12 | import { useStore, useAccountStore } from "@/store"; 13 | import { useRouter } from "next/navigation"; 14 | import { initDb } from "@/lib/db"; 15 | import { 16 | AlertDialog, 17 | AlertDialogAction, 18 | AlertDialogCancel, 19 | AlertDialogContent, 20 | AlertDialogDescription, 21 | AlertDialogFooter, 22 | AlertDialogHeader, 23 | AlertDialogTitle, 24 | AlertDialogTrigger, 25 | } from "@/components/ui/alert-dialog"; 26 | import { toast } from "sonner"; 27 | import { Button } from "@/components/ui/button"; 28 | import { Trash2Icon, Loader2Icon } from "lucide-react"; 29 | import { deleteDB } from "idb"; 30 | 31 | export default function Settings() { 32 | const accountStore = useStore(useAccountStore, (state) => state); 33 | const router = useRouter(); 34 | 35 | const [count, setCount] = useState({ 36 | account: 0, 37 | star: 0, 38 | }); 39 | const [deleteDBLoading, setDeleteDBLoading] = useState(false); 40 | 41 | async function getDBTableCount() { 42 | try { 43 | const db = await initDb(); 44 | const starCount = await db.count("stars"); 45 | const accountCount = await db.count("accounts"); 46 | 47 | setCount({ 48 | account: accountCount, 49 | star: starCount, 50 | }); 51 | } catch (error) { 52 | console.log(error); 53 | } 54 | } 55 | 56 | useEffect(() => { 57 | if (accountStore?.allAccount?.length === 0) router.replace("/login"); 58 | else getDBTableCount(); 59 | }); 60 | return ( 61 |
62 |
63 |

IndexedDB

64 |

Manage IndexedDB

65 |
66 | 67 |
68 |

Usage

69 | 70 | {/* current tables and amount */} 71 | 72 | 73 | Table 74 | Total entries 75 | 76 | 77 | 78 | 79 | accounts 80 | {count.account} 81 | 82 | 83 | stars 84 | {count.star} 85 | 86 | 87 |
88 |
89 | 90 |
91 |

Delete

92 |

93 | {" "} 94 | If you encounter some unexpected errors, you can try deleting 95 | IndexedDB. 96 |

97 | 98 | 99 | 112 | 113 | 114 | 115 | Are you absolutely sure? 116 | 117 | This action cannot be undone. This will permanently delete the 118 | IndexedDB. 119 | 120 | 121 | 122 | Cancel 123 | { 125 | try { 126 | setDeleteDBLoading(true); 127 | await deleteDB("Stargazers"); 128 | accountStore?.setAllAccount([]); 129 | accountStore?.setCurrentAccount(null); 130 | router.replace("/login"); 131 | toast.success("DB deleted"); 132 | } catch (error) { 133 | toast.error("Error deleting DB", { 134 | description: String(error), 135 | }); 136 | } finally { 137 | setDeleteDBLoading(false); 138 | } 139 | }} 140 | > 141 | Continue 142 | 143 | 144 | 145 | 146 |
147 |
148 | ); 149 | } 150 | -------------------------------------------------------------------------------- /components/ui/alert-dialog.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import * as React from "react" 4 | import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog" 5 | 6 | import { cn } from "@/lib/utils" 7 | import { buttonVariants } from "@/components/ui/button" 8 | 9 | const AlertDialog = AlertDialogPrimitive.Root 10 | 11 | const AlertDialogTrigger = AlertDialogPrimitive.Trigger 12 | 13 | const AlertDialogPortal = ({ 14 | className, 15 | ...props 16 | }: AlertDialogPrimitive.AlertDialogPortalProps) => ( 17 | 18 | ) 19 | AlertDialogPortal.displayName = AlertDialogPrimitive.Portal.displayName 20 | 21 | const AlertDialogOverlay = React.forwardRef< 22 | React.ElementRef, 23 | React.ComponentPropsWithoutRef 24 | >(({ className, children, ...props }, ref) => ( 25 | 33 | )) 34 | AlertDialogOverlay.displayName = AlertDialogPrimitive.Overlay.displayName 35 | 36 | const AlertDialogContent = React.forwardRef< 37 | React.ElementRef, 38 | React.ComponentPropsWithoutRef 39 | >(({ className, ...props }, ref) => ( 40 | 41 | 42 | 50 | 51 | )) 52 | AlertDialogContent.displayName = AlertDialogPrimitive.Content.displayName 53 | 54 | const AlertDialogHeader = ({ 55 | className, 56 | ...props 57 | }: React.HTMLAttributes) => ( 58 |
65 | ) 66 | AlertDialogHeader.displayName = "AlertDialogHeader" 67 | 68 | const AlertDialogFooter = ({ 69 | className, 70 | ...props 71 | }: React.HTMLAttributes) => ( 72 |
79 | ) 80 | AlertDialogFooter.displayName = "AlertDialogFooter" 81 | 82 | const AlertDialogTitle = React.forwardRef< 83 | React.ElementRef, 84 | React.ComponentPropsWithoutRef 85 | >(({ className, ...props }, ref) => ( 86 | 91 | )) 92 | AlertDialogTitle.displayName = AlertDialogPrimitive.Title.displayName 93 | 94 | const AlertDialogDescription = React.forwardRef< 95 | React.ElementRef, 96 | React.ComponentPropsWithoutRef 97 | >(({ className, ...props }, ref) => ( 98 | 103 | )) 104 | AlertDialogDescription.displayName = 105 | AlertDialogPrimitive.Description.displayName 106 | 107 | const AlertDialogAction = React.forwardRef< 108 | React.ElementRef, 109 | React.ComponentPropsWithoutRef 110 | >(({ className, ...props }, ref) => ( 111 | 116 | )) 117 | AlertDialogAction.displayName = AlertDialogPrimitive.Action.displayName 118 | 119 | const AlertDialogCancel = React.forwardRef< 120 | React.ElementRef, 121 | React.ComponentPropsWithoutRef 122 | >(({ className, ...props }, ref) => ( 123 | 132 | )) 133 | AlertDialogCancel.displayName = AlertDialogPrimitive.Cancel.displayName 134 | 135 | export { 136 | AlertDialog, 137 | AlertDialogTrigger, 138 | AlertDialogContent, 139 | AlertDialogHeader, 140 | AlertDialogFooter, 141 | AlertDialogTitle, 142 | AlertDialogDescription, 143 | AlertDialogAction, 144 | AlertDialogCancel, 145 | } 146 | -------------------------------------------------------------------------------- /components/ui/command.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import * as React from "react"; 4 | import { DialogProps } from "@radix-ui/react-dialog"; 5 | import { Command as CommandPrimitive } from "cmdk"; 6 | import { MagnifyingGlassIcon } from "@radix-ui/react-icons"; 7 | 8 | import { cn } from "@/lib/utils"; 9 | import { Dialog, DialogContent } from "@/components/ui/dialog"; 10 | 11 | const Command = React.forwardRef< 12 | React.ElementRef, 13 | React.ComponentPropsWithoutRef 14 | >(({ className, ...props }, ref) => ( 15 | 23 | )); 24 | Command.displayName = CommandPrimitive.displayName; 25 | 26 | interface CommandDialogProps extends DialogProps {} 27 | 28 | const CommandDialog = ({ children, ...props }: CommandDialogProps) => { 29 | return ( 30 | 31 | 32 | 33 | {children} 34 | 35 | 36 | 37 | ); 38 | }; 39 | 40 | const CommandInput = React.forwardRef< 41 | React.ElementRef, 42 | React.ComponentPropsWithoutRef 43 | >(({ className, ...props }, ref) => ( 44 |
45 | 46 | 54 |
55 | )); 56 | 57 | CommandInput.displayName = CommandPrimitive.Input.displayName; 58 | 59 | const CommandList = React.forwardRef< 60 | React.ElementRef, 61 | React.ComponentPropsWithoutRef 62 | >(({ className, ...props }, ref) => ( 63 | 68 | )); 69 | 70 | CommandList.displayName = CommandPrimitive.List.displayName; 71 | 72 | const CommandEmpty = React.forwardRef< 73 | React.ElementRef, 74 | React.ComponentPropsWithoutRef 75 | >((props, ref) => ( 76 | 81 | )); 82 | 83 | CommandEmpty.displayName = CommandPrimitive.Empty.displayName; 84 | 85 | const CommandGroup = React.forwardRef< 86 | React.ElementRef, 87 | React.ComponentPropsWithoutRef 88 | >(({ className, ...props }, ref) => ( 89 | 97 | )); 98 | 99 | CommandGroup.displayName = CommandPrimitive.Group.displayName; 100 | 101 | const CommandSeparator = React.forwardRef< 102 | React.ElementRef, 103 | React.ComponentPropsWithoutRef 104 | >(({ className, ...props }, ref) => ( 105 | 110 | )); 111 | CommandSeparator.displayName = CommandPrimitive.Separator.displayName; 112 | 113 | const CommandItem = React.forwardRef< 114 | React.ElementRef, 115 | React.ComponentPropsWithoutRef 116 | >(({ className, ...props }, ref) => ( 117 | 125 | )); 126 | 127 | CommandItem.displayName = CommandPrimitive.Item.displayName; 128 | 129 | const CommandShortcut = ({ 130 | className, 131 | ...props 132 | }: React.HTMLAttributes) => { 133 | return ( 134 | 141 | ); 142 | }; 143 | CommandShortcut.displayName = "CommandShortcut"; 144 | 145 | export { 146 | Command, 147 | CommandDialog, 148 | CommandInput, 149 | CommandList, 150 | CommandEmpty, 151 | CommandGroup, 152 | CommandItem, 153 | CommandShortcut, 154 | CommandSeparator, 155 | }; 156 | -------------------------------------------------------------------------------- /components/pagination.tsx: -------------------------------------------------------------------------------- 1 | import { Button } from "@/components/ui/button"; 2 | import { 3 | ChevronLeftIcon, 4 | ChevronRightIcon, 5 | DoubleArrowLeftIcon, 6 | DoubleArrowRightIcon, 7 | } from "@radix-ui/react-icons"; 8 | import { useStore } from "@/store/useStore"; 9 | import { useStarStore } from "@/store/star"; 10 | import { useAccountStore } from "@/store/account"; 11 | import { 12 | Select, 13 | SelectContent, 14 | SelectItem, 15 | SelectTrigger, 16 | SelectValue, 17 | } from "@/components/ui/select"; 18 | import { useKeyboardShortcut } from "@/lib/useKeyboardShortcut"; 19 | 20 | export default function Pagination() { 21 | const starStore = useStore(useStarStore, (state) => state)!; 22 | const accountStore = useStore(useAccountStore, (state) => state)!; 23 | 24 | useKeyboardShortcut(["arrowright"], () => { 25 | if ( 26 | starStore.pagination.page === 27 | Number(Math.ceil(starStore?.pagination?.total / 12)) 28 | ) 29 | return; 30 | 31 | starStore.setPagintion({ 32 | page: starStore?.pagination?.page! + 1, 33 | }); 34 | 35 | starStore.getStarFromIndexDB(accountStore.currentAccount?.login!); 36 | }); 37 | 38 | useKeyboardShortcut(["arrowleft"], () => { 39 | if (starStore?.pagination?.page <= 1) return; 40 | 41 | starStore.setPagintion({ 42 | page: starStore?.pagination?.page! - 1, 43 | }); 44 | 45 | starStore.getStarFromIndexDB(accountStore.currentAccount?.login!); 46 | }); 47 | 48 | return starStore?.pagination.total > 0 ? ( 49 |
50 |
51 | 52 | Total: {starStore?.pagination?.total} 53 | 54 |
55 |
56 |
57 | 58 | Items per page 59 | 60 | 88 |
89 | 90 |
91 | 106 | 121 | 139 | 157 | 158 | {starStore?.pagination?.page} /{" "} 159 | {Math.ceil( 160 | starStore?.pagination?.total / starStore.pagination.size 161 | )} 162 | 163 |
164 |
165 |
166 | ) : ( 167 | "" 168 | ); 169 | } 170 | -------------------------------------------------------------------------------- /components/account.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; 4 | import { cn } from "@/lib/utils"; 5 | import { 6 | Popover, 7 | PopoverContent, 8 | PopoverTrigger, 9 | } from "@/components/ui/popover"; 10 | import { useState } from "react"; 11 | import { Settings, PlusCircleIcon, CheckIcon } from "lucide-react"; 12 | import { Account } from "@/lib/db"; 13 | import { 14 | AlertDialog, 15 | AlertDialogCancel, 16 | AlertDialogContent, 17 | AlertDialogFooter, 18 | AlertDialogHeader, 19 | AlertDialogTitle, 20 | AlertDialogTrigger, 21 | } from "@/components/ui/alert-dialog"; 22 | import UserSearch from "@/components/user-search"; 23 | import { useStarStore, useAccountStore, useSettingStore } from "@/store"; 24 | import Link from "next/link"; 25 | import { Skeleton } from "@/components/ui/skeleton"; 26 | import { Separator } from "@/components/ui/separator"; 27 | 28 | export default function Account() { 29 | const [open, setOpen] = useState(false); 30 | const [dialogOpen, setDialogOpen] = useState(false); 31 | 32 | const { getStarFromIndexDB, fetchStars, setQueryForm, syncSearchQueryForm } = 33 | useStarStore(); 34 | const { currentAccount, setCurrentAccount, allAccount, refreshAllAccount } = 35 | useAccountStore(); 36 | const { settings } = useSettingStore(); 37 | 38 | return ( 39 | 40 | 41 | {currentAccount ? ( 42 |
43 | 44 | 45 | 46 | 47 | 48 | 49 |
50 | ) : null} 51 |
52 | 53 |
54 |
55 | {allAccount?.map((account) => ( 56 |
{ 59 | if (currentAccount?.login === account.login) return; 60 | setCurrentAccount(account); 61 | setOpen(false); 62 | setQueryForm({ 63 | startTimeId: settings.dateRange, 64 | keyword: "", 65 | language: "", 66 | }); 67 | 68 | syncSearchQueryForm(); 69 | 70 | if (!account.lastSyncAt) { 71 | await fetchStars(account.login); 72 | await refreshAllAccount(); 73 | } else { 74 | getStarFromIndexDB(account.login); 75 | } 76 | }} 77 | className={[ 78 | "flex gap-2 py-1.5 px-2 rounded-sm justify-between items-center hover:bg-accent", 79 | currentAccount?.login === account.login 80 | ? "cursor-not-allowed" 81 | : "cursor-pointer", 82 | ].join(" ")} 83 | > 84 |
85 | 88 | 89 | 90 | 91 | 92 | 93 |
94 | {account.name} 95 |
96 |
97 | {currentAccount!.login === account.login && ( 98 | 99 | )} 100 |
101 | ))} 102 |
103 | 104 | 105 | 106 |
107 |
108 | 109 | Add account 110 |
111 | 112 |
setOpen(false)} 114 | className="text-sm rounded-sm flex py-1.5 px-2 gap-1 items-center hover:bg-accent cursor-pointer" 115 | > 116 | 117 | Settings 118 |
119 | 120 |
121 |
122 | 123 | 124 | 125 | Add account 126 | 127 | 128 | { 130 | setOpen(false); 131 | setDialogOpen(false); 132 | }} 133 | > 134 | 135 | 136 | Cancel 137 | 138 | 139 | 140 |
141 |
142 |
143 |
144 | ); 145 | } 146 | -------------------------------------------------------------------------------- /store/star.ts: -------------------------------------------------------------------------------- 1 | import { Account } from '@/lib/db'; 2 | import { create } from 'zustand' 3 | import { Star, initDb, searchStar, addStar } from '@/lib/db' 4 | import { subMonths, getUnixTime, subYears } from "date-fns"; 5 | import { cloneDeep } from 'lodash' 6 | import { toast } from 'sonner' 7 | 8 | export interface QueryForm { 9 | startTimeId?: string 10 | startTime?: Date | number 11 | endTime: Date | number 12 | keyword: string 13 | language: string 14 | } 15 | 16 | export interface Pagination { 17 | page: number 18 | size: number 19 | total: number 20 | } 21 | 22 | interface StarStore { 23 | stars: Star[] 24 | loading: boolean 25 | queryForm: QueryForm 26 | searchQueryForm: QueryForm 27 | pagination: Pagination 28 | 29 | fetchStars: (username: string) => Promise 30 | getStarFromIndexDB: (username: string) => Promise 31 | setQueryForm: (queryForm: Partial) => void 32 | syncSearchQueryForm: () => void 33 | setPagintion: (pagination: Partial) => void 34 | } 35 | 36 | export const startTimeMap: Record = { 37 | 0: subMonths(new Date(), 3), 38 | 1: subMonths(new Date(), 6), 39 | 2: subYears(new Date(), 1), 40 | 3: subYears(new Date(), 2), 41 | 4: subYears(new Date(), 5), 42 | 5: subYears(new Date(), 10), 43 | 6: subYears(new Date(), 20), 44 | } 45 | 46 | export const useStarStore = create((set, get) => { 47 | return { 48 | stars: [], 49 | loading: false, 50 | queryForm: { 51 | startTimeId: '2', 52 | startTime: startTimeMap[2], 53 | endTime: new Date(), 54 | keyword: '', 55 | language: '', 56 | }, 57 | searchQueryForm: { 58 | startTimeId: '2', 59 | startTime: startTimeMap[2], 60 | endTime: new Date(), 61 | keyword: '', 62 | language: '', 63 | }, 64 | pagination: { 65 | page: 1, 66 | size: 12, 67 | total: 0 68 | }, 69 | 70 | getStarFromIndexDB: async (username: string) => { 71 | try { 72 | set(() => ({ 73 | loading: true 74 | })) 75 | 76 | const db = await initDb() 77 | 78 | const searchQueryForm = cloneDeep(get().searchQueryForm) 79 | 80 | searchQueryForm.startTime = searchQueryForm.startTimeId ? getUnixTime(startTimeMap[searchQueryForm.startTimeId]) : -Infinity 81 | 82 | const results = await searchStar(db, username, { 83 | ...searchQueryForm, 84 | page: get().pagination.page, 85 | size: get().pagination.size 86 | }) 87 | set(() => ({ 88 | stars: results.stars, 89 | pagination: { 90 | ...get().pagination, 91 | total: results.total 92 | } 93 | })) 94 | } catch (error) { 95 | console.log(error) 96 | } finally { 97 | set(() => ({ 98 | loading: false 99 | })) 100 | } 101 | }, 102 | 103 | fetchStars: async (username: string) => { 104 | try { 105 | set(() => ({ 106 | loading: true 107 | })) 108 | 109 | toast('Start fetching...', { 110 | description: 'It may take a few minutes, depending on the number of stars for this account..', 111 | duration: 5000 112 | }) 113 | 114 | const db = await initDb() 115 | 116 | const addTransactions: any[] = [] 117 | 118 | const fetchByCursor = async (cursor: string) => { 119 | const response = await fetch(`/api/gh/stars/${username}/${cursor}`); 120 | const data = await response.json(); 121 | 122 | if (data.errors) { 123 | throw new Error(data.errors); 124 | } 125 | 126 | const stars = data.data.stars 127 | const pageInfo = data.data.pageInfo 128 | 129 | const transactions: any[] = stars.map((star: Star) => { 130 | return star 131 | }) 132 | 133 | addTransactions.push(...transactions) 134 | 135 | if (pageInfo.hasNextPage) { 136 | await fetchByCursor(pageInfo.endCursor) 137 | } 138 | } 139 | 140 | await fetchByCursor('') 141 | 142 | const additionPromise = addTransactions.reduce((prev, cur) => { 143 | return prev.then(() => { 144 | return addStar(db, cur) 145 | }) 146 | }, Promise.resolve()) 147 | 148 | await additionPromise 149 | 150 | const transaction = db.transaction('accounts', 'readwrite') 151 | const store = transaction.objectStore('accounts') 152 | const account = (await store.get(username))! 153 | const updateData: Account = { 154 | ...account, 155 | lastSyncAt: Date.now().toString(), 156 | } 157 | 158 | await store.put(updateData) 159 | 160 | const searchQueryForm = cloneDeep(get().searchQueryForm) 161 | searchQueryForm.startTime = searchQueryForm.startTimeId ? getUnixTime(startTimeMap[searchQueryForm.startTimeId]) : -Infinity 162 | const results = await searchStar(db, username, { 163 | ...searchQueryForm, 164 | page: get().pagination.page, 165 | size: get().pagination.size 166 | }) 167 | 168 | set(() => ({ 169 | stars: results.stars, 170 | pagination: { 171 | ...get().pagination, 172 | total: results.total 173 | } 174 | })) 175 | } catch (error) { 176 | console.log(error) 177 | } finally { 178 | set(() => ({ 179 | loading: false 180 | })) 181 | } 182 | }, 183 | 184 | setQueryForm: (form: Partial) => { 185 | set(() => ({ 186 | queryForm: { 187 | ...get().queryForm, 188 | ...form 189 | } 190 | })) 191 | }, 192 | 193 | syncSearchQueryForm: () => { 194 | set(() => ({ 195 | searchQueryForm: { 196 | ...get().queryForm, 197 | } 198 | })) 199 | }, 200 | 201 | setPagintion: (pagination: Partial) => { 202 | set(() => ({ 203 | pagination: { 204 | ...get().pagination, 205 | ...pagination, 206 | } 207 | })) 208 | }, 209 | } 210 | }) 211 | -------------------------------------------------------------------------------- /components/analyze.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect, useRef, useState } from "react"; 2 | import * as echarts from "echarts/core"; 3 | import { GridComponent } from "echarts/components"; 4 | import { BarChart } from "echarts/charts"; 5 | import { CanvasRenderer } from "echarts/renderers"; 6 | import { 7 | Dialog, 8 | DialogContent, 9 | DialogHeader, 10 | DialogTitle, 11 | DialogTrigger, 12 | } from "@/components/ui/dialog"; 13 | import { Language, Owner, analyze } from "@/lib/analyze"; 14 | import { useStarStore, useAccountStore } from "@/store"; 15 | import { Button } from "@/components/ui/button"; 16 | import { ActivityIcon } from "lucide-react"; 17 | 18 | echarts.use([GridComponent, BarChart, CanvasRenderer]); 19 | 20 | export default function Analyze() { 21 | const accountStore = useAccountStore(); 22 | const starStore = useStarStore(); 23 | 24 | const [dialogOpen, setDialogOpen] = useState(false); 25 | 26 | const ownerChartRef = useRef(null); 27 | let ownerChart: echarts.ECharts | null = null; 28 | 29 | const languageChartRef = useRef(null); 30 | let languageChart: echarts.ECharts | null = null; 31 | 32 | async function renderOwners(owners: Owner[]) { 33 | try { 34 | const richs: Record< 35 | string, 36 | { 37 | height: number; 38 | width: number; 39 | align: string; 40 | backgroundColor: { 41 | image: string; 42 | }; 43 | } 44 | > | null = {}; 45 | 46 | owners.forEach((item) => { 47 | richs[item.name] = { 48 | height: 20, 49 | width: 20, 50 | align: "right", 51 | backgroundColor: { 52 | image: item.avatar, 53 | }, 54 | }; 55 | }); 56 | 57 | ownerChart = echarts.init(ownerChartRef.current); 58 | ownerChart.setOption({ 59 | grid: { 60 | left: 0, 61 | top: 0, 62 | bottom: 0, 63 | containLabel: true, 64 | }, 65 | xAxis: { 66 | show: false, 67 | type: "value", 68 | }, 69 | yAxis: { 70 | inverse: true, 71 | offset: 0, 72 | axisLine: { 73 | show: false, 74 | }, 75 | axisTick: { 76 | show: false, 77 | }, 78 | splitLine: { 79 | show: false, 80 | }, 81 | type: "category", 82 | data: owners.map((i) => i.name), 83 | axisLabel: { 84 | formatter: function (value: any) { 85 | return `{${value}|}\n{value|${ 86 | value.length > 10 ? value.slice(0, 7) + "..." : value 87 | }}`; 88 | }, 89 | rich: { 90 | value: { 91 | lineHeight: 16, 92 | align: "right", 93 | }, 94 | ...richs, 95 | }, 96 | }, 97 | }, 98 | series: [ 99 | { 100 | data: owners.map((i) => i.count), 101 | type: "bar", 102 | label: { 103 | show: true, 104 | position: "right", 105 | }, 106 | itemStyle: { 107 | color: "rgb(0, 98, 236)", 108 | borderRadius: 5, 109 | }, 110 | }, 111 | ], 112 | }); 113 | } catch (error) { 114 | console.log(error); 115 | } 116 | } 117 | 118 | async function renderLanguages(languages: Language[]) { 119 | try { 120 | languageChart = echarts.init(languageChartRef.current); 121 | languageChart.setOption({ 122 | grid: { 123 | top: 0, 124 | left: 0, 125 | bottom: 0, 126 | containLabel: true, 127 | }, 128 | xAxis: { 129 | show: false, 130 | type: "value", 131 | }, 132 | yAxis: { 133 | inverse: true, 134 | offset: 0, 135 | axisLine: { 136 | show: false, 137 | }, 138 | axisTick: { 139 | show: false, 140 | }, 141 | splitLine: { 142 | show: false, 143 | }, 144 | axisLabel: { 145 | formatter: function (value: any) { 146 | return `${value.length > 10 ? value.slice(0, 7) + "..." : value}`; 147 | }, 148 | }, 149 | 150 | type: "category", 151 | data: languages.map((i) => i.name), 152 | }, 153 | series: [ 154 | { 155 | data: languages.map((i) => { 156 | return { 157 | value: i.count, 158 | itemStyle: { 159 | color: i.color, 160 | borderRadius: 5, 161 | }, 162 | }; 163 | }), 164 | type: "bar", 165 | label: { 166 | show: true, 167 | position: "right", 168 | }, 169 | }, 170 | ], 171 | }); 172 | } catch (error) { 173 | console.log(error); 174 | } 175 | } 176 | 177 | useEffect(() => { 178 | if (dialogOpen) { 179 | analyze( 180 | accountStore.currentAccount?.login!, 181 | starStore.searchQueryForm 182 | ).then(({ owners, languages }) => { 183 | renderOwners(owners); 184 | renderLanguages(languages); 185 | }); 186 | } 187 | 188 | return () => { 189 | ownerChart?.dispose(); 190 | languageChart?.dispose(); 191 | }; 192 | }, [dialogOpen]); 193 | 194 | return ( 195 | setDialogOpen(open)}> 196 | 197 | 201 | 202 | 203 | 204 | Analyze 205 | 206 |
207 |
208 | Top 5 Most Star Owners 209 |
210 |
214 |
215 | Top 5 Most Star Languages 216 |
217 |
221 |
222 |
223 |
224 | ); 225 | } 226 | -------------------------------------------------------------------------------- /components/ui/dropdown-menu.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import * as React from "react"; 4 | import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu"; 5 | import { CheckIcon, ChevronRightIcon, CircleIcon } from "@radix-ui/react-icons"; 6 | 7 | import { cn } from "@/lib/utils"; 8 | 9 | const DropdownMenu = DropdownMenuPrimitive.Root; 10 | 11 | const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger; 12 | 13 | const DropdownMenuGroup = DropdownMenuPrimitive.Group; 14 | 15 | const DropdownMenuPortal = DropdownMenuPrimitive.Portal; 16 | 17 | const DropdownMenuSub = DropdownMenuPrimitive.Sub; 18 | 19 | const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup; 20 | 21 | const DropdownMenuSubTrigger = React.forwardRef< 22 | React.ElementRef, 23 | React.ComponentPropsWithoutRef & { 24 | inset?: boolean; 25 | } 26 | >(({ className, inset, children, ...props }, ref) => ( 27 | 36 | {children} 37 | 38 | 39 | )); 40 | DropdownMenuSubTrigger.displayName = 41 | DropdownMenuPrimitive.SubTrigger.displayName; 42 | 43 | const DropdownMenuSubContent = React.forwardRef< 44 | React.ElementRef, 45 | React.ComponentPropsWithoutRef 46 | >(({ className, ...props }, ref) => ( 47 | 55 | )); 56 | DropdownMenuSubContent.displayName = 57 | DropdownMenuPrimitive.SubContent.displayName; 58 | 59 | const DropdownMenuContent = React.forwardRef< 60 | React.ElementRef, 61 | React.ComponentPropsWithoutRef 62 | >(({ className, sideOffset = 4, ...props }, ref) => ( 63 | 64 | 73 | 74 | )); 75 | DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName; 76 | 77 | const DropdownMenuItem = React.forwardRef< 78 | React.ElementRef, 79 | React.ComponentPropsWithoutRef & { 80 | inset?: boolean; 81 | } 82 | >(({ className, inset, ...props }, ref) => ( 83 | 92 | )); 93 | DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName; 94 | 95 | const DropdownMenuCheckboxItem = React.forwardRef< 96 | React.ElementRef, 97 | React.ComponentPropsWithoutRef 98 | >(({ className, children, checked, ...props }, ref) => ( 99 | 108 | 109 | 110 | 111 | 112 | 113 | {children} 114 | 115 | )); 116 | DropdownMenuCheckboxItem.displayName = 117 | DropdownMenuPrimitive.CheckboxItem.displayName; 118 | 119 | const DropdownMenuRadioItem = React.forwardRef< 120 | React.ElementRef, 121 | React.ComponentPropsWithoutRef 122 | >(({ className, children, ...props }, ref) => ( 123 | 131 | 132 | 133 | 134 | 135 | 136 | {children} 137 | 138 | )); 139 | DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName; 140 | 141 | const DropdownMenuLabel = React.forwardRef< 142 | React.ElementRef, 143 | React.ComponentPropsWithoutRef & { 144 | inset?: boolean; 145 | } 146 | >(({ className, inset, ...props }, ref) => ( 147 | 156 | )); 157 | DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName; 158 | 159 | const DropdownMenuSeparator = React.forwardRef< 160 | React.ElementRef, 161 | React.ComponentPropsWithoutRef 162 | >(({ className, ...props }, ref) => ( 163 | 168 | )); 169 | DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName; 170 | 171 | const DropdownMenuShortcut = ({ 172 | className, 173 | ...props 174 | }: React.HTMLAttributes) => { 175 | return ( 176 | 180 | ); 181 | }; 182 | DropdownMenuShortcut.displayName = "DropdownMenuShortcut"; 183 | 184 | export { 185 | DropdownMenu, 186 | DropdownMenuTrigger, 187 | DropdownMenuContent, 188 | DropdownMenuItem, 189 | DropdownMenuCheckboxItem, 190 | DropdownMenuRadioItem, 191 | DropdownMenuLabel, 192 | DropdownMenuSeparator, 193 | DropdownMenuShortcut, 194 | DropdownMenuGroup, 195 | DropdownMenuPortal, 196 | DropdownMenuSub, 197 | DropdownMenuSubContent, 198 | DropdownMenuSubTrigger, 199 | DropdownMenuRadioGroup, 200 | }; 201 | -------------------------------------------------------------------------------- /components/user-search.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import * as React from "react"; 4 | import { HeightIcon } from "@radix-ui/react-icons"; 5 | 6 | import { cn } from "@/lib/utils"; 7 | import { Button } from "@/components/ui/button"; 8 | import { debounce } from "lodash"; 9 | import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; 10 | import { Command, CommandInput } from "@/components/ui/command"; 11 | import { 12 | Popover, 13 | PopoverContent, 14 | PopoverTrigger, 15 | } from "@/components/ui/popover"; 16 | import { Skeleton } from "@/components/ui/skeleton"; 17 | import { initDb, addAccount, getAllAccount } from "@/lib/db"; 18 | import { toast } from "sonner"; 19 | import { GitHubLogoIcon } from "@radix-ui/react-icons"; 20 | import { useRouter } from "next/navigation"; 21 | import { useAccountStore, useSettingStore, useStarStore } from "@/store"; 22 | import Link from "next/link"; 23 | import { GITHUB_CLIENT_ID } from "@/lib/constant"; 24 | 25 | export default function UserSearch({ callback }: { callback?: () => void }) { 26 | const [open, setOpen] = React.useState(false); 27 | const [user, setUser] = React.useState<{ 28 | value: string; 29 | label: string; 30 | avatar: string; 31 | id: string; 32 | }>(); 33 | 34 | const [users, setUsers] = React.useState< 35 | { 36 | value: string; 37 | label: string; 38 | avatar: string; 39 | id: string; 40 | }[] 41 | >([]); 42 | 43 | const [loading, setLoading] = React.useState(false); 44 | 45 | const router = useRouter(); 46 | 47 | const accountStore = useAccountStore(); 48 | 49 | const settingStore = useSettingStore(); 50 | const starStore = useStarStore(); 51 | 52 | const handleInputChange = debounce(async (event: any) => { 53 | try { 54 | setLoading(true); 55 | const inputVal = event.target.value; 56 | if (inputVal === "") { 57 | return; 58 | } 59 | const res = await fetch(`/api/gh/users?name=${inputVal}`); 60 | const { data } = await res.json(); 61 | 62 | const users = data.data.search.nodes 63 | ?.filter((i: any) => i?.login) 64 | ?.map((item: any) => { 65 | return { 66 | value: item.login, 67 | label: item.name || item.login, 68 | id: item.id, 69 | avatar: item.avatarUrl, 70 | }; 71 | }); 72 | 73 | setUsers(users); 74 | } catch (error) { 75 | console.log(error); 76 | } finally { 77 | setLoading(false); 78 | } 79 | }, 500); 80 | 81 | async function handleAddAccount() { 82 | try { 83 | if (user) { 84 | const db = await initDb(); 85 | await addAccount(db, { 86 | login: user.value, 87 | avatarUrl: user.avatar, 88 | name: user.label, 89 | from: "search", 90 | lastSyncAt: "", 91 | addedAt: new Date().toISOString(), 92 | }); 93 | 94 | const accounts = await getAllAccount(db); 95 | accountStore.setAllAccount(accounts); 96 | 97 | if (callback) { 98 | callback(); 99 | } else { 100 | router.replace("/"); 101 | } 102 | 103 | if (settingStore.settings.autoSwitch) { 104 | const findAccount = accounts.find( 105 | (account) => account.login === user.value 106 | ); 107 | if (findAccount) { 108 | accountStore.setCurrentAccount(findAccount); 109 | const dateRange = settingStore.settings.dateRange; 110 | starStore.setQueryForm({ 111 | startTimeId: dateRange, 112 | keyword: "", 113 | language: "", 114 | }); 115 | starStore.syncSearchQueryForm(); 116 | await starStore.fetchStars(findAccount?.login!); 117 | await accountStore.refreshAllAccount(); 118 | } 119 | } 120 | 121 | if (!accountStore.currentAccount) { 122 | accountStore.setCurrentAccount(accounts[0]); 123 | } 124 | 125 | toast.success("Account added"); 126 | } 127 | } catch (error) { 128 | toast.error("Error adding account", { 129 | description: String(error), 130 | }); 131 | } 132 | } 133 | 134 | return ( 135 | <> 136 | 143 | 144 | Continue with GitHub 145 | 146 | 147 | 148 |
149 |
150 | 151 |
152 |
153 | 154 | Or Search a user 155 | 156 |
157 |
158 | 159 | 160 | 181 | 182 | 183 | 184 | 185 | 186 | {users?.length ? ( 187 |
188 | {users.map((user) => ( 189 |
{ 192 | setUser(user); 193 | setOpen(false); 194 | }} 195 | className="flex items-center p-2 gap-2 rounded cursor-pointer hover:bg-slate-200 dark:hover:bg-slate-800" 196 | > 197 | 198 | 199 | 200 | 201 | 202 | 203 | 204 | {user.label} 205 |
206 | ))} 207 |
208 | ) : loading ? ( 209 |
210 | {Array(5) 211 | .fill(0) 212 | .map((_, index) => { 213 | return ( 214 |
218 | 219 | 220 |
221 | ); 222 | })} 223 |
224 | ) : ( 225 |
226 | No user found 227 |
228 | )} 229 |
230 |
231 | 238 | 239 | ); 240 | } 241 | -------------------------------------------------------------------------------- /components/repo-list.tsx: -------------------------------------------------------------------------------- 1 | import { useStarStore } from "@/store/star"; 2 | import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; 3 | import { cn } from "@/lib/utils"; 4 | import styles from "@/styles/repo.module.css"; 5 | import { StarIcon } from "@radix-ui/react-icons"; 6 | import { GitForkIcon, HomeIcon } from "lucide-react"; 7 | import { FadeInWhenVisible } from "./motion"; 8 | import Link from "next/link"; 9 | import { emojify } from "node-emoji"; 10 | import { Skeleton } from "@/components/ui/skeleton"; 11 | import dayjs from "dayjs"; 12 | import relativeTime from "dayjs/plugin/relativeTime"; 13 | import { useWindowSize } from "react-use"; 14 | import Confetti from "react-confetti"; 15 | import { useState } from "react"; 16 | import { toast } from "sonner"; 17 | import { Badge } from "./ui/badge"; 18 | 19 | dayjs.extend(relativeTime); 20 | 21 | export default function RepoList() { 22 | const { stars } = useStarStore((state) => state); 23 | 24 | const { width, height } = useWindowSize(); 25 | 26 | const [confettiShow, setConfettiShow] = useState(false); 27 | 28 | return ( 29 | <> 30 |
31 | {stars.length > 0 ? ( 32 | stars.map((star) => { 33 | return ( 34 | 35 |
36 |
37 |
38 | 43 | 46 | 47 | 48 | 49 | 50 | 51 |
52 | {star.owner} 53 |
54 | 55 | 60 |
63 | {star.repo} 64 |
65 | {star.isTemplate ? ( 66 | Template 67 | ) : null} 68 | 69 | {star.isArchived ? ( 70 | 74 | Archive 75 | 76 | ) : null} 77 | 78 |
81 | {emojify(star?.description || "")} 82 |
83 |
84 |
85 |
86 | {star.language ? ( 87 |
88 |
92 |
93 | {star.language} 94 |
95 |
96 | ) : null} 97 | 101 |
102 | 103 | 104 | {star.stargazerCount.toLocaleString()} 105 | 106 |
107 | 108 | 112 |
113 | 114 | 115 | {star.forkCount.toLocaleString()} 116 | 117 |
118 | 119 | {star?.homepageUrl ? ( 120 | star?.homepageUrl === "https://stargazers.dev" ? ( 121 |
{ 123 | setConfettiShow(true); 124 | toast.success("You are alreay here!", { 125 | description: "Thank you star Stargazers.", 126 | }); 127 | }} 128 | className="cursor-pointer flex items-center gap-1 text-muted-foreground" 129 | > 130 | 131 | Home 132 |
133 | ) : ( 134 | 139 | 140 | Home 141 | 142 | ) 143 | ) : null} 144 |
145 |
146 | {/*
147 | Updated {dayjs(star.updatedAt).fromNow()} 148 |
*/} 149 |
150 |
151 |
152 |
153 |
154 | ); 155 | }) 156 | ) : ( 157 |
158 |
159 | No Results Found 160 |
161 |
162 | )} 163 |
164 | {confettiShow ? ( 165 | { 171 | setConfettiShow(false); 172 | confetti?.reset(); 173 | }} 174 | /> 175 | ) : null} 176 | 177 | ); 178 | } 179 | -------------------------------------------------------------------------------- /app/settings/page.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { Separator } from "@/components/ui/separator"; 4 | import { useStore, useAccountStore, useStarStore } from "@/store"; 5 | import { 6 | AlertDialog, 7 | AlertDialogAction, 8 | AlertDialogCancel, 9 | AlertDialogContent, 10 | AlertDialogDescription, 11 | AlertDialogFooter, 12 | AlertDialogHeader, 13 | AlertDialogTitle, 14 | AlertDialogTrigger, 15 | } from "@/components/ui/alert-dialog"; 16 | import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; 17 | import { Skeleton } from "@/components/ui/skeleton"; 18 | import { cn } from "@/lib/utils"; 19 | import dayjs from "dayjs"; 20 | import { Button } from "@/components/ui/button"; 21 | import { Trash2Icon, RefreshCcwIcon, Loader2Icon } from "lucide-react"; 22 | import { toast } from "sonner"; 23 | import { useEffect, useState } from "react"; 24 | import { Account } from "@/lib/db"; 25 | import { useRouter } from "next/navigation"; 26 | import relativeTime from "dayjs/plugin/relativeTime"; 27 | 28 | dayjs.extend(relativeTime); 29 | 30 | export default function Settings() { 31 | const accountStore = useStore(useAccountStore, (state) => state); 32 | const starStore = useStore(useStarStore, (state) => state); 33 | 34 | const router = useRouter(); 35 | 36 | const [currentSyncIndex, setCurrentSyncIndex] = useState(0); 37 | const [currentDeleteIndex, setCurrentDeleteIndex] = useState(0); 38 | const [deleteLoading, setDeleteLoading] = useState(false); 39 | 40 | async function handleDeleteAccount(account: Account, index: number) { 41 | try { 42 | setDeleteLoading(true); 43 | setCurrentDeleteIndex(index); 44 | await accountStore?.deleteAccount(account); 45 | toast.success("Account deleted"); 46 | if (accountStore?.allAccount?.length === 1) router.replace("/login"); 47 | } catch (error) { 48 | toast.error("Error deleting account", { 49 | description: String(error), 50 | }); 51 | } finally { 52 | setDeleteLoading(false); 53 | } 54 | } 55 | 56 | async function handleSync(login: string, index: number) { 57 | try { 58 | setCurrentSyncIndex(index); 59 | 60 | await starStore?.fetchStars(login); 61 | 62 | await accountStore?.refreshAllAccount(); 63 | toast.success("Account synced"); 64 | } catch (error) { 65 | console.log(error); 66 | toast.error("Error syncing account", { 67 | description: String(error), 68 | }); 69 | } finally { 70 | } 71 | } 72 | 73 | useEffect(() => { 74 | if (accountStore?.allAccount?.length === 0) router.replace("/login"); 75 | }); 76 | 77 | return ( 78 |
79 |
80 |

Account

81 |

82 | Manage existing accounts and sync star data from GitHub. 83 |

84 |
85 | 86 |
87 | {accountStore?.allAccount?.length ? ( 88 |
89 | {accountStore?.allAccount?.map((account, index) => ( 90 |
91 |
92 | 93 | 94 | 95 | 96 | 97 | 98 |
99 | {account.name} 100 | 101 | {account.lastSyncAt 102 | ? "Synced on " + 103 | dayjs(Number(account.lastSyncAt)).fromNow() 104 | : "Never"} 105 | 106 |
107 |
108 | 109 |
110 | 136 | 137 | 138 | 167 | 168 | 169 | 170 | 171 | Are you absolutely sure? 172 | 173 | 174 | This action cannot be undone. This will permanently 175 | delete the account. 176 | 177 | 178 | 179 | Cancel 180 | handleDeleteAccount(account, index)} 182 | > 183 | Continue 184 | 185 | 186 | 187 | 188 |
189 |
190 | ))} 191 |
192 | ) : ( 193 |
194 | {Array(5) 195 | .fill(0) 196 | .map((_, index) => { 197 | return ( 198 |
199 | 200 | 201 |
202 | ); 203 | })} 204 |
205 | )} 206 |
207 |
208 | ); 209 | } 210 | --------------------------------------------------------------------------------