commit 0f0a3ab28e387f21e4f1615568136e35e6499f7a Author: cc Date: Fri Apr 10 20:08:02 2026 +0000 initial: refactor web frontend from monorepo with WASM SQLite3 engine diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml new file mode 100644 index 0000000..e344856 --- /dev/null +++ b/.github/workflows/build.yml @@ -0,0 +1,75 @@ +name: Build and Deploy + +on: + push: + branches: [main] + + repository_dispatch: + types: [data-updated] + + workflow_dispatch: + +permissions: + contents: read + pages: write + id-token: write + +jobs: + build: + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: "20" + cache: "npm" + + - name: Setup Pages + uses: actions/configure-pages@v5 + id: configure-pages + with: + static_site_generator: next + + - name: Install dependencies + run: npm ci + + - name: Build with Next.js + run: npm run build + env: + NEXT_PUBLIC_BASE_PATH: ${{ steps.configure-pages.outputs.base_path }} + + - name: Download latest data release + run: | + curl -sL https://api.github.com/repos/ChiChou/entdb-data/releases/latest \ + | jq -r '.assets[] | select(.name == "data.tar.gz") | .browser_download_url' \ + | xargs -r curl -L -o data.tar.gz + mkdir -p out/data + tar -xzf data.tar.gz -C out/data + + - name: Download SQLite database + run: | + curl -sL https://api.github.com/repos/ChiChou/entdb-data/releases/latest \ + | jq -r '.assets[] | select(.name == "ent.db") | .browser_download_url' \ + | xargs -r curl -L -o out/data/ent.db + + - name: Upload artifact + uses: actions/upload-pages-artifact@v3 + with: + path: ./out + + deploy: + environment: + name: github-pages + url: ${{ steps.deployment.outputs.page_url }} + needs: build + runs-on: ubuntu-latest + if: github.ref == 'refs/heads/main' + + steps: + - name: Deploy to GitHub Pages + id: deployment + uses: actions/deploy-pages@v4 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..9a9c9f0 --- /dev/null +++ b/.gitignore @@ -0,0 +1,36 @@ +data/ +public/data + +*.tar.gz +*.db + +node_modules/ +/.pnp +.pnp.* +.yarn/* +!.yarn/patches +!.yarn/plugins +!.yarn/releases +!.yarn/versions + +/coverage + +/.next/ +/out/ + +/build + +.DS_Store +*.pem + +npm-debug.log* +yarn-debug.log* +yarn-error.log* +.pnpm-debug.log* + +.env* + +.vercel + +*.tsbuildinfo +next-env.d.ts diff --git a/.nojekyll b/.nojekyll new file mode 100644 index 0000000..e69de29 diff --git a/README.md b/README.md new file mode 100644 index 0000000..6c33aa7 --- /dev/null +++ b/README.md @@ -0,0 +1,24 @@ +# entdb-web + +Web frontend for the Entitlement Database. + +Uses a WASM SQLite3 query engine as the primary data source for rich queries, +with a KV-based fallback for browsers that don't support WebAssembly. + +Built as a static Next.js site deployed to GitHub Pages. + +## Data Sources + +The frontend uses a dual-engine approach: + +1. **WASM Engine** (primary) — Loads `ent.db` SQLite database into the browser + via `@sqlite.org/sqlite-wasm`. Supports arbitrary SQL queries for rich data + views and cross-version analysis. + +2. **KV Engine** (fallback) — Uses pre-built static KV files (index + blob) + with HTTP Range requests. Used when WebAssembly is not available. + +## Related Repos + +- [entdb-indexer](https://github.com/ChiChou/entdb-indexer) — Crontab workflow to discover and index firmware +- [entdb-data](https://github.com/ChiChou/entdb-data) — Raw entitlement data repository diff --git a/components.json b/components.json new file mode 100644 index 0000000..3289f23 --- /dev/null +++ b/components.json @@ -0,0 +1,21 @@ +{ + "$schema": "https://ui.shadcn.com/schema.json", + "style": "new-york", + "rsc": true, + "tsx": true, + "tailwind": { + "config": "", + "css": "src/app/globals.css", + "baseColor": "neutral", + "cssVariables": true, + "prefix": "" + }, + "aliases": { + "components": "@/components", + "utils": "@/lib/utils", + "ui": "@/components/ui", + "lib": "@/lib", + "hooks": "@/hooks" + }, + "iconLibrary": "lucide" +} diff --git a/eslint.config.mjs b/eslint.config.mjs new file mode 100644 index 0000000..b0e644d --- /dev/null +++ b/eslint.config.mjs @@ -0,0 +1 @@ +import rootConfig from "../../eslint.config.mjs"; diff --git a/next.config.ts b/next.config.ts new file mode 100644 index 0000000..65c102b --- /dev/null +++ b/next.config.ts @@ -0,0 +1,7 @@ +import type { NextConfig } from "next"; + +const nextConfig: NextConfig = { + output: "export", +}; + +export default nextConfig; diff --git a/package.json b/package.json new file mode 100644 index 0000000..0b01b62 --- /dev/null +++ b/package.json @@ -0,0 +1,48 @@ +{ + "name": "entdb-web", + "version": "0.1.0", + "private": true, + "scripts": { + "dev": "next dev --turbopack", + "build": "next build --turbopack", + "start": "next start", + "lint": "eslint" + }, + "dependencies": { + "@radix-ui/react-accordion": "^1.2.12", + "@radix-ui/react-checkbox": "^1.3.3", + "@radix-ui/react-collapsible": "^1.1.12", + "@radix-ui/react-dialog": "^1.1.15", + "@radix-ui/react-popover": "^1.1.15", + "@radix-ui/react-separator": "^1.1.7", + "@radix-ui/react-slot": "^1.2.3", + "@radix-ui/react-tabs": "^1.1.13", + "@radix-ui/react-tooltip": "^1.2.8", + "@sqlite.org/sqlite-wasm": "^3.49.1-build3", + "class-variance-authority": "^0.7.1", + "clsx": "^2.1.1", + "cmdk": "^1.1.1", + "lucide-react": "^0.541.0", + "next": "15.5.0", + "next-themes": "^0.4.6", + "react": "19.1.0", + "react-dom": "19.1.0", + "react-syntax-highlighter": "^15.6.3", + "sonner": "^2.0.7", + "tailwind-merge": "^3.3.1", + "use-debounce": "^10.0.5" + }, + "devDependencies": { + "@eslint/eslintrc": "^3", + "@tailwindcss/postcss": "^4", + "@types/node": "^20", + "@types/react": "^19", + "@types/react-dom": "^19", + "@types/react-syntax-highlighter": "^15.5.13", + "eslint": "^9", + "eslint-config-next": "15.5.0", + "tailwindcss": "^4", + "tw-animate-css": "^1.3.7", + "typescript": "^5" + } +} diff --git a/postcss.config.mjs b/postcss.config.mjs new file mode 100644 index 0000000..c7bcb4b --- /dev/null +++ b/postcss.config.mjs @@ -0,0 +1,5 @@ +const config = { + plugins: ["@tailwindcss/postcss"], +}; + +export default config; diff --git a/src/app/favicon.ico b/src/app/favicon.ico new file mode 100644 index 0000000..718d6fe Binary files /dev/null and b/src/app/favicon.ico differ diff --git a/src/app/globals.css b/src/app/globals.css new file mode 100644 index 0000000..a56193e --- /dev/null +++ b/src/app/globals.css @@ -0,0 +1,146 @@ +@import "tailwindcss"; +@import "tw-animate-css"; + +@custom-variant dark (&:is(.dark *)); + +@theme inline { + --color-background: var(--background); + --color-foreground: var(--foreground); + --font-sans: var(--font-geist-sans); + --font-mono: var(--font-geist-mono); + --color-sidebar-ring: var(--sidebar-ring); + --color-sidebar-border: var(--sidebar-border); + --color-sidebar-accent-foreground: var(--sidebar-accent-foreground); + --color-sidebar-accent: var(--sidebar-accent); + --color-sidebar-primary-foreground: var(--sidebar-primary-foreground); + --color-sidebar-primary: var(--sidebar-primary); + --color-sidebar-foreground: var(--sidebar-foreground); + --color-sidebar: var(--sidebar); + --color-chart-5: var(--chart-5); + --color-chart-4: var(--chart-4); + --color-chart-3: var(--chart-3); + --color-chart-2: var(--chart-2); + --color-chart-1: var(--chart-1); + --color-ring: var(--ring); + --color-input: var(--input); + --color-border: var(--border); + --color-destructive: var(--destructive); + --color-accent-foreground: var(--accent-foreground); + --color-accent: var(--accent); + --color-muted-foreground: var(--muted-foreground); + --color-muted: var(--muted); + --color-secondary-foreground: var(--secondary-foreground); + --color-secondary: var(--secondary); + --color-primary-foreground: var(--primary-foreground); + --color-primary: var(--primary); + --color-popover-foreground: var(--popover-foreground); + --color-popover: var(--popover); + --color-card-foreground: var(--card-foreground); + --color-card: var(--card); + --radius-sm: calc(var(--radius) - 4px); + --radius-md: calc(var(--radius) - 2px); + --radius-lg: var(--radius); + --radius-xl: calc(var(--radius) + 4px); +} + +:root { + --radius: 0.625rem; + --background: oklch(1 0 0); + --foreground: oklch(0.145 0 0); + --card: oklch(1 0 0); + --card-foreground: oklch(0.145 0 0); + --popover: oklch(1 0 0); + --popover-foreground: oklch(0.145 0 0); + --primary: oklch(0.205 0 0); + --primary-foreground: oklch(0.985 0 0); + --secondary: oklch(0.97 0 0); + --secondary-foreground: oklch(0.205 0 0); + --muted: oklch(0.97 0 0); + --muted-foreground: oklch(0.556 0 0); + --accent: oklch(0.97 0 0); + --accent-foreground: oklch(0.205 0 0); + --destructive: oklch(0.577 0.245 27.325); + --border: oklch(0.922 0 0); + --input: oklch(0.922 0 0); + --ring: oklch(0.708 0 0); + --chart-1: oklch(0.646 0.222 41.116); + --chart-2: oklch(0.6 0.118 184.704); + --chart-3: oklch(0.398 0.07 227.392); + --chart-4: oklch(0.828 0.189 84.429); + --chart-5: oklch(0.769 0.188 70.08); + --sidebar: oklch(0.985 0 0); + --sidebar-foreground: oklch(0.145 0 0); + --sidebar-primary: oklch(0.205 0 0); + --sidebar-primary-foreground: oklch(0.985 0 0); + --sidebar-accent: oklch(0.97 0 0); + --sidebar-accent-foreground: oklch(0.205 0 0); + --sidebar-border: oklch(0.922 0 0); + --sidebar-ring: oklch(0.708 0 0); +} + +.dark { + --background: oklch(0.145 0 0); + --foreground: oklch(0.985 0 0); + --card: oklch(0.205 0 0); + --card-foreground: oklch(0.985 0 0); + --popover: oklch(0.205 0 0); + --popover-foreground: oklch(0.985 0 0); + --primary: oklch(0.922 0 0); + --primary-foreground: oklch(0.205 0 0); + --secondary: oklch(0.269 0 0); + --secondary-foreground: oklch(0.985 0 0); + --muted: oklch(0.269 0 0); + --muted-foreground: oklch(0.708 0 0); + --accent: oklch(0.269 0 0); + --accent-foreground: oklch(0.985 0 0); + --destructive: oklch(0.704 0.191 22.216); + --border: oklch(1 0 0 / 10%); + --input: oklch(1 0 0 / 15%); + --ring: oklch(0.556 0 0); + --chart-1: oklch(0.488 0.243 264.376); + --chart-2: oklch(0.696 0.17 162.48); + --chart-3: oklch(0.769 0.188 70.08); + --chart-4: oklch(0.627 0.265 303.9); + --chart-5: oklch(0.645 0.246 16.439); + --sidebar: oklch(0.205 0 0); + --sidebar-foreground: oklch(0.985 0 0); + --sidebar-primary: oklch(0.488 0.243 264.376); + --sidebar-primary-foreground: oklch(0.985 0 0); + --sidebar-accent: oklch(0.269 0 0); + --sidebar-accent-foreground: oklch(0.985 0 0); + --sidebar-border: oklch(1 0 0 / 10%); + --sidebar-ring: oklch(0.556 0 0); +} + +@layer base { + * { + @apply border-border outline-ring/50; + } + body { + @apply bg-background text-foreground; + } +} + +@layer base { + :root { + --sidebar: oklch(0.985 0 0); + --sidebar-foreground: oklch(0.145 0 0); + --sidebar-primary: oklch(0.205 0 0); + --sidebar-primary-foreground: oklch(0.985 0 0); + --sidebar-accent: oklch(0.97 0 0); + --sidebar-accent-foreground: oklch(0.205 0 0); + --sidebar-border: oklch(0.922 0 0); + --sidebar-ring: oklch(0.708 0 0); + } + + .dark { + --sidebar: oklch(0.205 0 0); + --sidebar-foreground: oklch(0.985 0 0); + --sidebar-primary: oklch(0.488 0.243 264.376); + --sidebar-primary-foreground: oklch(0.985 0 0); + --sidebar-accent: oklch(0.269 0 0); + --sidebar-accent-foreground: oklch(0.985 0 0); + --sidebar-border: oklch(1 0 0 / 10%); + --sidebar-ring: oklch(0.439 0 0); + } +} diff --git a/src/app/layout.tsx b/src/app/layout.tsx new file mode 100644 index 0000000..fb8c6b0 --- /dev/null +++ b/src/app/layout.tsx @@ -0,0 +1,43 @@ +import type { Metadata } from "next"; +import { Geist, Geist_Mono } from "next/font/google"; +import "./globals.css"; + +import { NavTop } from "@/components/navtop"; +import { Toaster } from "@/components/ui/sonner"; +import { Suspense } from "react"; + +const geistSans = Geist({ + variable: "--font-geist-sans", + subsets: ["latin"], +}); + +const geistMono = Geist_Mono({ + variable: "--font-geist-mono", + subsets: ["latin"], +}); + +export const metadata: Metadata = { + title: "Entitlement Database", + description: + "Open source entitlement database for iOS and macOS binaries that you can host yourself.", +}; + +export default function RootLayout({ + children, +}: Readonly<{ + children: React.ReactNode; +}>) { + return ( + + + + + +
{children}
+
+ + + ); +} diff --git a/src/app/os/bin/page.tsx b/src/app/os/bin/page.tsx new file mode 100644 index 0000000..add5501 --- /dev/null +++ b/src/app/os/bin/page.tsx @@ -0,0 +1,161 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { redirect, useSearchParams } from "next/navigation"; +import { + createElement, + Prism as SyntaxHighlighter, +} from "react-syntax-highlighter"; +import { tomorrow } from "react-syntax-highlighter/dist/esm/styles/prism"; + +import { CopyButton } from "@/components/copy-button"; + +import { addBasePath } from "@/lib/env"; +import { createEngine } from "@/lib/engine"; + +function prettifyXml(src: string) { + const xmlDoc = new DOMParser().parseFromString(src, "application/xml"); + const xsltDoc = new DOMParser().parseFromString( + ` + + + + + + + `, + "application/xml", + ); + + const xsltProcessor = new XSLTProcessor(); + xsltProcessor.importStylesheet(xsltDoc); + const resultDoc = xsltProcessor.transformToDocument(xmlDoc); + const resultXml = new XMLSerializer().serializeToString(resultDoc); + return resultXml; +} + +export default function BinaryDetail() { + const params = useSearchParams(); + const os = params.get("os"); + const path = params.get("path"); + + const [group, build] = os ? os.split("/") : ["", ""]; + + useEffect(() => { + if (os && path) { + document.title = `${path} | ${os} - Entitlement Database`; + } + }); + + if (typeof os !== "string" || typeof path !== "string") { + redirect("/404"); + } + + const [loading, setLoading] = useState(false); + const [xml, setXML] = useState(""); + const [xmlKeys, setXMLKeys] = useState>(new Set()); + + useEffect(() => { + async function load() { + const engine = await createEngine(group); + const rawXml = await engine.getBinaryXML(build, path!); + + try { + const prettified = prettifyXml(rawXml); + setXML(prettified); + + const parser = new DOMParser(); + const doc = parser.parseFromString(rawXml, "application/xml"); + const keys = new Set(); + const keyElements = doc.querySelectorAll("dict > key"); + keyElements.forEach((el) => keys.add(el.textContent || "")); + setXMLKeys(keys); + } catch { + setXML(rawXml); + } + } + + setLoading(true); + load().finally(() => setLoading(false)); + }, [group, build, path]); + + return ( +
+
+
+
+

Entitlements of

+

+ + {path} + +

+
+ {!loading && xml && } +
+ + {loading &&

Loading...

} + {!loading && xml && ( + { + function addLink(node: rendererNode) { + if (node.type === "text" && xmlKeys.has(node.value as string)) { + return { + type: "element", + tagName: "span", + children: [ + { + type: "element", + tagName: "a", + children: [ + { + type: "text", + value: node.value as string, + } as rendererNode, + ], + properties: { + className: ["text-blue-200", "hover:underline"], + href: addBasePath( + `/os/find?key=${encodeURIComponent( + node.value as string, + )}&os=${encodeURIComponent(os!)}`, + ), + }, + } as rendererNode, + ], + properties: { className: ["linked-key"] }, + } as rendererNode; + } + + if (node.children) { + node.children = node.children.map(addLink); + } + return node; + } + + return rows.map((row, i) => { + return createElement({ + node: addLink(row), + stylesheet, + useInlineStyles, + key: `code-segment-${i}`, + }); + }); + }} + > + {xml} + + )} +
+
+ ); +} diff --git a/src/app/os/files/page.tsx b/src/app/os/files/page.tsx new file mode 100644 index 0000000..6af1427 --- /dev/null +++ b/src/app/os/files/page.tsx @@ -0,0 +1,58 @@ +"use client"; + +import { useState, useEffect } from "react"; +import { useSearchParams } from "next/navigation"; + +import FileSystem from "@/components/filesystem"; +import { createEngine } from "@/lib/engine"; + +export default function Files() { + const params = useSearchParams(); + const os = params.get("os") as string; + const [group, build] = os ? os.split("/") : ["", ""]; + + const [loading, setLoading] = useState(true); + const [files, setFiles] = useState([]); + + useEffect(() => { + setLoading(true); + createEngine(group) + .then((engine) => engine.getPaths(build)) + .then(setFiles) + .finally(() => setLoading(false)); + }, [group, build]); + + return ( +
+ {loading ? ( +
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ ) : ( + + )} +
+ ); +} diff --git a/src/app/os/find/page.tsx b/src/app/os/find/page.tsx new file mode 100644 index 0000000..9588f53 --- /dev/null +++ b/src/app/os/find/page.tsx @@ -0,0 +1,53 @@ +"use client"; + +import FileSystem from "@/components/filesystem"; + +import { createEngine } from "@/lib/engine"; + +import { redirect, useSearchParams } from "next/navigation"; +import { useEffect, useState } from "react"; + +export default function FindByKey() { + const params = useSearchParams(); + const os = params.get("os"); + const key = params.get("key"); + + const [group, build] = os ? os.split("/") : ["", ""]; + + useEffect(() => { + if (os && key) { + document.title = `Find "${key}" in ${os} - Entitlement Database`; + } + }); + + if (typeof os !== "string" || typeof key !== "string") { + redirect("/404"); + } + + const [paths, setPaths] = useState([]); + + useEffect(() => { + async function fetchPaths() { + if (!key) return; + + const engine = await createEngine(group); + const result = await engine.getPathsForKey(build, key); + setPaths(result); + } + fetchPaths(); + }, [group, build, key]); + + return ( +
+
+

+ Binaries that have the following entitlement: +

+

+ {key} +

+
+ +
+ ); +} diff --git a/src/app/os/keys/page.tsx b/src/app/os/keys/page.tsx new file mode 100644 index 0000000..698ba45 --- /dev/null +++ b/src/app/os/keys/page.tsx @@ -0,0 +1,88 @@ +"use client"; + +import { useState, useEffect } from "react"; +import { useSearchParams } from "next/navigation"; +import { useDebounce } from "use-debounce"; +import Link from "next/link"; + +import { addBasePath } from "@/lib/env"; +import { Input } from "@/components/ui/input"; +import { Badge } from "@/components/ui/badge"; + +import { createEngine } from "@/lib/engine"; + +export default function Keys() { + const params = useSearchParams(); + const os = params.get("os") as string; + const [group, build] = os ? os.split("/") : ["", ""]; + + const [loading, setLoading] = useState(true); + const [keys, setKeys] = useState([]); + const [filtered, setFiltered] = useState([]); + const [keyword, setKeyword] = useState(""); + + const [value] = useDebounce(keyword, 200); + + useEffect(() => { + async function load() { + const engine = await createEngine(group); + const allKeys = await engine.getKeys(build); + setKeys(allKeys); + } + + setLoading(true); + load().finally(() => setLoading(false)); + }, [group, build]); + + useEffect(() => { + setFiltered( + keys.filter((key) => key.toLowerCase().includes(value.toLowerCase())), + ); + }, [value, keys]); + + return ( +
+
+ setKeyword(e.target.value)} + className="p-2 border rounded w-full inset-shadow-accent pr-10" + /> + {keyword && ( + + )} +
+ + {loading ? ( +
+ {Array.from({ length: 8 }).map((_, index) => ( +
+ ))} +
+ ) : ( +
+ {filtered.map((key, index) => ( + + {key} + + ))} +
+ )} +
+ ); +} diff --git a/src/app/os/layout.tsx b/src/app/os/layout.tsx new file mode 100644 index 0000000..acea7b6 --- /dev/null +++ b/src/app/os/layout.tsx @@ -0,0 +1,57 @@ +"use client"; + +import { useSearchParams } from "next/navigation"; + +import { + Breadcrumb, + BreadcrumbItem, + BreadcrumbLink, + BreadcrumbList, + BreadcrumbSeparator, +} from "@/components/ui/breadcrumb"; + +import { addBasePath } from "@/lib/env"; +import { useEffect } from "react"; + +export default function OSDetailLayout({ + children, +}: { + children: React.ReactNode; +}) { + const params = useSearchParams(); + const os = params.get("os"); + + useEffect(() => { + if (os) document.title = `${os || ""} - Entitlement Database`; + }, [os]); + + return ( +
+
+ + + + Home + + + + + {os} + + | + + Search Keys + + | + + Search Paths + + + + +
+ +
{children}
+
+ ); +} diff --git a/src/app/os/page.tsx b/src/app/os/page.tsx new file mode 100644 index 0000000..fccd10e --- /dev/null +++ b/src/app/os/page.tsx @@ -0,0 +1,15 @@ +"use client"; + +import { addBasePath } from "@/lib/env"; +import { useSearchParams, redirect } from "next/navigation"; + +export default function OSDetail() { + const params = useSearchParams(); + const os = params.get("os"); + + if (typeof os !== "string") { + return
Invalid OS
; + } + + redirect(addBasePath(`/os/keys?os=${os}`)); +} diff --git a/src/app/page.tsx b/src/app/page.tsx new file mode 100644 index 0000000..1136ca1 --- /dev/null +++ b/src/app/page.tsx @@ -0,0 +1,14 @@ +import OSList from "@/components/oslist"; + +export default async function Home() { + return ( +
+

+ Entitlement Database +

+
+ +
+
+ ); +} diff --git a/src/components/autocomplete.tsx b/src/components/autocomplete.tsx new file mode 100644 index 0000000..1fc518c --- /dev/null +++ b/src/components/autocomplete.tsx @@ -0,0 +1,145 @@ +import { cn } from "@/lib/utils"; +import { Command as CommandPrimitive } from "cmdk"; +import { Check } from "lucide-react"; +import { useMemo, useState } from "react"; +import { + Command, + CommandEmpty, + CommandGroup, + CommandItem, + CommandList, +} from "./ui/command"; +import { Input } from "./ui/input"; +import { Popover, PopoverAnchor, PopoverContent } from "./ui/popover"; +import { Skeleton } from "./ui/skeleton"; + +type Props = { + selectedValue: T; + onSelectedValueChange: (value: T) => void; + searchValue: string; + onSearchValueChange: (value: string) => void; + items: { value: T; label: string }[]; + isLoading?: boolean; + emptyMessage?: string; + placeholder?: string; +}; + +export function AutoComplete({ + selectedValue, + onSelectedValueChange, + searchValue, + onSearchValueChange, + items, + isLoading, + emptyMessage = "No items.", + placeholder = "Search...", +}: Props) { + const [open, setOpen] = useState(false); + + const labels = useMemo( + () => + items.reduce( + (acc, item) => { + acc[item.value] = item.label; + return acc; + }, + {} as Record, + ), + [items], + ); + + const reset = () => { + onSelectedValueChange("" as T); + onSearchValueChange(""); + }; + + const onInputBlur = (e: React.FocusEvent) => { + if ( + !e.relatedTarget?.hasAttribute("cmdk-list") && + labels[selectedValue] !== searchValue + ) { + reset(); + } + }; + + const onSelectItem = (inputValue: string) => { + if (inputValue === selectedValue) { + reset(); + } else { + onSelectedValueChange(inputValue as T); + onSearchValueChange(labels[inputValue] ?? ""); + } + setOpen(false); + }; + + return ( +
+ + + + setOpen(e.key !== "Escape")} + onMouseDown={() => setOpen((open) => !!searchValue || !open)} + onFocus={() => setOpen(true)} + onBlur={onInputBlur} + > + + + + {!open && + +
+ ); +} diff --git a/src/components/copy-button.tsx b/src/components/copy-button.tsx new file mode 100644 index 0000000..f4eafa4 --- /dev/null +++ b/src/components/copy-button.tsx @@ -0,0 +1,21 @@ +"use client"; + +import { Copy } from "lucide-react"; +import { Button } from "./ui/button"; +import { toast } from "sonner"; + +export function CopyButton({ text }: { text: string }) { + return ( +
+ +
+ ); +} diff --git a/src/components/filesystem.tsx b/src/components/filesystem.tsx new file mode 100644 index 0000000..306be4f --- /dev/null +++ b/src/components/filesystem.tsx @@ -0,0 +1,63 @@ +import { + Collapsible, + CollapsibleContent, + CollapsibleTrigger, +} from "@/components/ui/collapsible"; + +import { Button } from "@/components/ui/button"; + +import filesToTree, { type TreeWithFullPath } from "@/lib/tree"; +import Link from "next/link"; + +function Tree({ item, os }: { item: TreeWithFullPath; os: string }) { + return ( +
    + {Object.entries(item).map(([key, value]) => { + if (typeof value === "string") { + return ( +
  • + + /{key} + +
  • + ); + } else { + return ( +
  • +
      + + + + + + + + +
    +
  • + ); + } + })} +
+ ); +} + +export default function FileSystem({ + list, + os, +}: { + list: string[]; + os: string; +}) { + const tree = filesToTree(list); + return ( +
+ +
+ ); +} diff --git a/src/components/navtop.tsx b/src/components/navtop.tsx new file mode 100644 index 0000000..54f152f --- /dev/null +++ b/src/components/navtop.tsx @@ -0,0 +1,31 @@ +import Link from "next/link"; + +export function NavTop() { + return ( +
+

+ + entdb + +

+ +
+ ); +} diff --git a/src/components/oslist.tsx b/src/components/oslist.tsx new file mode 100644 index 0000000..b9ac00e --- /dev/null +++ b/src/components/oslist.tsx @@ -0,0 +1,163 @@ +"use client"; + +import Link from "next/link"; +import { useEffect, useState } from "react"; + +import { Group, OS } from "@/lib/types"; +import { addBasePath } from "@/lib/env"; +import { Skeleton } from "./ui/skeleton"; +import { Checkbox } from "./ui/checkbox"; + +function responseOK(r: Response) { + if (!r.ok) { + throw new Error(`Failed to fetch resource at ${r.url}`); + } + return r; +} + +function compareVersion(a: string, b: string) { + const l1 = a.split(".").map(Number); + const l2 = b.split(".").map(Number); + const len = Math.max(l1.length, l2.length); + + for (let i = 0; i < len; i++) { + const v1 = l1[i] || 0; + const v2 = l2[i] || 0; + if (v1 !== v2) return v1 - v2; + } + + return 0; +} + +export default function OSList() { + const [showLess, setShowLess] = useState(true); + const [loading, setLoading] = useState(true); + const [groups, setGroups] = useState([]); + const [highlights, setHighlights] = useState>(new Set()); + + useEffect(() => { + const set: Set = new Set(); + for (const group of groups) { + group.list.sort((a, b) => compareVersion(b.version, a.version)); + + if (group.name === "osx") { + group.list.forEach((item) => set.add(item.build)); + } else { + const bucket: Map = new Map(); + group.list.forEach((item) => { + const [major] = item.version.split(".", 1); + const key = major.toString(); + if (!bucket.has(key)) { + bucket.set(key, [item]); + } else { + bucket.get(key)!.push(item); + } + }); + bucket.values().forEach((items) => { + items.sort((a, b) => compareVersion(b.version, a.version)); + const [first] = items; + set.add(first?.build); + }); + } + } + setHighlights(set); + }, [groups]); + + useEffect(() => { + setLoading(true); + + fetch(addBasePath("/data/groups.json")) + .then(responseOK) + .then((r) => r.json() as Promise) + .then(async (groupList: string[]) => + Promise.all( + groupList.map(async (group) => { + const response = await fetch( + addBasePath(`/data/${group}/list.json`), + ).then(responseOK); + + const data = await response.json(); + + return { + name: group, + list: data, + }; + }), + ), + ) + .then((groups) => { + setGroups(groups); + }) + .finally(() => setLoading(false)); + }, []); + + return ( +
+ {loading && ( +
+
+ + +
+ + {[1, 2, 3].map((group) => ( +
+ +
+ {[1, 2, 3, 4, 5, 6, 7, 8].map((item) => ( +
+
+ + +
+
+ ))} +
+
+ ))} +
+ )} + + {!loading && groups.length === 0 && ( +
Failed to fetch OS list
+ )} + + {!loading && ( +
+ setShowLess(Boolean(checked))} + /> + +
+ )} + + {groups.map((group) => ( +
+

{group.name}

+
    + {group.list + .filter((os) => !showLess || highlights.has(os.build)) + .map((os, index) => ( +
  • + +
    +

    {os.name}

    +
    {os.build}
    +
    + +
  • + ))} +
+
+ ))} +
+ ); +} diff --git a/src/components/ui/accordion.tsx b/src/components/ui/accordion.tsx new file mode 100644 index 0000000..4a8cca4 --- /dev/null +++ b/src/components/ui/accordion.tsx @@ -0,0 +1,66 @@ +"use client" + +import * as React from "react" +import * as AccordionPrimitive from "@radix-ui/react-accordion" +import { ChevronDownIcon } from "lucide-react" + +import { cn } from "@/lib/utils" + +function Accordion({ + ...props +}: React.ComponentProps) { + return +} + +function AccordionItem({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function AccordionTrigger({ + className, + children, + ...props +}: React.ComponentProps) { + return ( + + svg]:rotate-180", + className + )} + {...props} + > + {children} + + + + ) +} + +function AccordionContent({ + className, + children, + ...props +}: React.ComponentProps) { + return ( + +
{children}
+
+ ) +} + +export { Accordion, AccordionItem, AccordionTrigger, AccordionContent } diff --git a/src/components/ui/badge.tsx b/src/components/ui/badge.tsx new file mode 100644 index 0000000..0205413 --- /dev/null +++ b/src/components/ui/badge.tsx @@ -0,0 +1,46 @@ +import * as React from "react" +import { Slot } from "@radix-ui/react-slot" +import { cva, type VariantProps } from "class-variance-authority" + +import { cn } from "@/lib/utils" + +const badgeVariants = cva( + "inline-flex items-center justify-center rounded-md border px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-[color,box-shadow] overflow-hidden", + { + variants: { + variant: { + default: + "border-transparent bg-primary text-primary-foreground [a&]:hover:bg-primary/90", + secondary: + "border-transparent bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90", + destructive: + "border-transparent bg-destructive text-white [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60", + outline: + "text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground", + }, + }, + defaultVariants: { + variant: "default", + }, + } +) + +function Badge({ + className, + variant, + asChild = false, + ...props +}: React.ComponentProps<"span"> & + VariantProps & { asChild?: boolean }) { + const Comp = asChild ? Slot : "span" + + return ( + + ) +} + +export { Badge, badgeVariants } diff --git a/src/components/ui/breadcrumb.tsx b/src/components/ui/breadcrumb.tsx new file mode 100644 index 0000000..eb88f32 --- /dev/null +++ b/src/components/ui/breadcrumb.tsx @@ -0,0 +1,109 @@ +import * as React from "react" +import { Slot } from "@radix-ui/react-slot" +import { ChevronRight, MoreHorizontal } from "lucide-react" + +import { cn } from "@/lib/utils" + +function Breadcrumb({ ...props }: React.ComponentProps<"nav">) { + return