initial: refactor web frontend from monorepo with WASM SQLite3 engine

This commit is contained in:
cc
2026-04-10 20:08:02 +00:00
commit 0f0a3ab28e
52 changed files with 3490 additions and 0 deletions
+75
View File
@@ -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
+36
View File
@@ -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
View File
+24
View File
@@ -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
+21
View File
@@ -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"
}
+1
View File
@@ -0,0 +1 @@
import rootConfig from "../../eslint.config.mjs";
+7
View File
@@ -0,0 +1,7 @@
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
output: "export",
};
export default nextConfig;
+48
View File
@@ -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"
}
}
+5
View File
@@ -0,0 +1,5 @@
const config = {
plugins: ["@tailwindcss/postcss"],
};
export default config;
Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

+146
View File
@@ -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);
}
}
+43
View File
@@ -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 (
<html lang="en">
<body
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
>
<NavTop />
<Toaster />
<Suspense>
<main className="flex flex-col">{children}</main>
</Suspense>
</body>
</html>
);
}
+161
View File
@@ -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(
`<xsl:stylesheet version="1.0"
xmlns:xsl="http://www.w3.org/1999/XSL/Transform">
<xsl:output omit-xml-declaration="yes" indent="yes"/>
<xsl:template match="node()|@*">
<xsl:copy>
<xsl:apply-templates select="node()|@*"/>
</xsl:copy>
</xsl:template>
</xsl:stylesheet>`,
"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<string>("");
const [xmlKeys, setXMLKeys] = useState<Set<string>>(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<string>();
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 (
<div>
<main className="space-y-6">
<div className="flex items-center justify-between mb-3">
<div>
<h2 className="text-xl font-semibold">Entitlements of</h2>
<p>
<code className="text-red-800 break-all font-thin text-sm">
{path}
</code>
</p>
</div>
{!loading && xml && <CopyButton text={xml} />}
</div>
{loading && <p>Loading...</p>}
{!loading && xml && (
<SyntaxHighlighter
language="xml"
showLineNumbers={true}
style={tomorrow}
customStyle={{
margin: 0,
borderRadius: "0.5rem",
fontSize: "0.875rem",
}}
renderer={({ rows, stylesheet, useInlineStyles }) => {
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}
</SyntaxHighlighter>
)}
</main>
</div>
);
}
+58
View File
@@ -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<string[]>([]);
useEffect(() => {
setLoading(true);
createEngine(group)
.then((engine) => engine.getPaths(build))
.then(setFiles)
.finally(() => setLoading(false));
}, [group, build]);
return (
<div className="text-left">
{loading ? (
<div className="space-y-2">
<div className="flex items-center space-x-2">
<div className="w-4 h-4 bg-gray-300 rounded animate-pulse"></div>
<div className="h-4 bg-gray-300 rounded w-32 animate-pulse"></div>
</div>
<div className="ml-6 space-y-2">
<div className="flex items-center space-x-2">
<div className="w-4 h-4 bg-gray-300 rounded animate-pulse"></div>
<div className="h-4 bg-gray-300 rounded w-24 animate-pulse"></div>
</div>
<div className="ml-6 space-y-1">
<div className="h-3 bg-gray-300 rounded w-20 animate-pulse"></div>
<div className="h-3 bg-gray-300 rounded w-16 animate-pulse"></div>
<div className="h-3 bg-gray-300 rounded w-28 animate-pulse"></div>
</div>
<div className="flex items-center space-x-2">
<div className="w-4 h-4 bg-gray-300 rounded animate-pulse"></div>
<div className="h-4 bg-gray-300 rounded w-20 animate-pulse"></div>
</div>
<div className="ml-6 space-y-1">
<div className="h-3 bg-gray-300 rounded w-24 animate-pulse"></div>
<div className="h-3 bg-gray-300 rounded w-18 animate-pulse"></div>
</div>
</div>
</div>
) : (
<FileSystem os={os} list={files} />
)}
</div>
);
}
+53
View File
@@ -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<string[]>([]);
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 (
<div>
<header>
<h1 className="text-gray-800">
Binaries that have the following entitlement:
</h1>
<p>
<code className="text-sm break-all text-red-700">{key}</code>
</p>
</header>
<FileSystem os={os} list={paths} />
</div>
);
}
+88
View File
@@ -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<string[]>([]);
const [filtered, setFiltered] = useState<string[]>([]);
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 (
<div>
<div className="relative w-full max-w-md mb-4">
<Input
type="text"
placeholder="Filter keys..."
value={keyword}
onChange={(e) => setKeyword(e.target.value)}
className="p-2 border rounded w-full inset-shadow-accent pr-10"
/>
{keyword && (
<button
onClick={() => setKeyword("")}
className="absolute right-2 top-1/2 transform -translate-y-1/2 text-gray-500 hover:text-gray-700"
>
</button>
)}
</div>
{loading ? (
<div className="space-y-2">
{Array.from({ length: 8 }).map((_, index) => (
<div
key={index}
className="h-8 bg-gray-200 rounded animate-pulse"
style={{ width: `${60 + Math.random() * 40}%` }}
/>
))}
</div>
) : (
<div className="flex w-full flex-wrap gap-2 overflow-x-clip">
{filtered.map((key, index) => (
<Badge
variant="outline"
key={index}
className="font-mono break-all text-sm"
>
<Link href={`/os/find?key=${key}&os=${os}`}>{key}</Link>
</Badge>
))}
</div>
)}
</div>
);
}
+57
View File
@@ -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 (
<div className="p-8" suppressHydrationWarning>
<header className="mb-8">
<Breadcrumb>
<BreadcrumbList>
<BreadcrumbItem>
<BreadcrumbLink href={addBasePath("/")}>Home</BreadcrumbLink>
</BreadcrumbItem>
<BreadcrumbSeparator />
<BreadcrumbItem>
<BreadcrumbLink href={addBasePath(`/os/keys?os=${os}`)}>
{os}
</BreadcrumbLink>
|
<BreadcrumbLink href={addBasePath(`/os/keys?os=${os}`)}>
Search Keys
</BreadcrumbLink>
|
<BreadcrumbLink href={addBasePath(`/os/files?os=${os}`)}>
Search Paths
</BreadcrumbLink>
</BreadcrumbItem>
</BreadcrumbList>
</Breadcrumb>
</header>
<div>{children}</div>
</div>
);
}
+15
View File
@@ -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 <div className="p-8">Invalid OS</div>;
}
redirect(addBasePath(`/os/keys?os=${os}`));
}
+14
View File
@@ -0,0 +1,14 @@
import OSList from "@/components/oslist";
export default async function Home() {
return (
<div className="font-sans">
<h1 className="text-2xl md:text-4xl text-center md-mt-16 mt-8">
Entitlement Database
</h1>
<div className="items-center justify-center m-4 md:m-16">
<OSList />
</div>
</div>
);
}
+145
View File
@@ -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<T extends string> = {
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<T extends string>({
selectedValue,
onSelectedValueChange,
searchValue,
onSearchValueChange,
items,
isLoading,
emptyMessage = "No items.",
placeholder = "Search...",
}: Props<T>) {
const [open, setOpen] = useState(false);
const labels = useMemo(
() =>
items.reduce(
(acc, item) => {
acc[item.value] = item.label;
return acc;
},
{} as Record<string, string>,
),
[items],
);
const reset = () => {
onSelectedValueChange("" as T);
onSearchValueChange("");
};
const onInputBlur = (e: React.FocusEvent<HTMLInputElement>) => {
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 (
<div className="flex items-center">
<Popover open={open} onOpenChange={setOpen}>
<Command shouldFilter={false}>
<PopoverAnchor asChild>
<CommandPrimitive.Input
asChild
value={searchValue}
onValueChange={onSearchValueChange}
onKeyDown={(e) => setOpen(e.key !== "Escape")}
onMouseDown={() => setOpen((open) => !!searchValue || !open)}
onFocus={() => setOpen(true)}
onBlur={onInputBlur}
>
<Input placeholder={placeholder} />
</CommandPrimitive.Input>
</PopoverAnchor>
{!open && <CommandList aria-hidden="true" className="hidden" />}
<PopoverContent
asChild
onOpenAutoFocus={(e) => e.preventDefault()}
onInteractOutside={(e) => {
if (
e.target instanceof Element &&
e.target.hasAttribute("cmdk-input")
) {
e.preventDefault();
}
}}
className="w-[--radix-popover-trigger-width] p-0"
>
<CommandList>
{isLoading && (
<CommandPrimitive.Loading>
<div className="p-1">
<Skeleton className="h-6 w-full" />
</div>
</CommandPrimitive.Loading>
)}
{items.length > 0 && !isLoading ? (
<CommandGroup>
{items.map((option) => (
<CommandItem
key={option.value}
value={option.value}
onMouseDown={(e) => e.preventDefault()}
onSelect={onSelectItem}
>
<Check
className={cn(
"mr-2 h-4 w-4",
selectedValue === option.value
? "opacity-100"
: "opacity-0",
)}
/>
{option.label}
</CommandItem>
))}
</CommandGroup>
) : null}
{!isLoading ? (
<CommandEmpty>{emptyMessage ?? "No items."}</CommandEmpty>
) : null}
</CommandList>
</PopoverContent>
</Command>
</Popover>
</div>
);
}
+21
View File
@@ -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 (
<div>
<Button
variant="outline"
onClick={() => {
navigator.clipboard.writeText(text);
toast("Copied");
}}
>
<Copy className="w-4 h-4" />
</Button>
</div>
);
}
+63
View File
@@ -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 (
<ul className="ml-2 pl-2 overflow-x-hidden">
{Object.entries(item).map(([key, value]) => {
if (typeof value === "string") {
return (
<li key={value} className="font-mono break-all text-sm m-2">
<Link
href={`/os/bin?path=${encodeURIComponent(value)}&os=${os}`}
className="hover:underline"
>
/{key}
</Link>
</li>
);
} else {
return (
<li key={key}>
<ul className="pl-2 border-l ml-2">
<Collapsible defaultOpen={true}>
<CollapsibleTrigger asChild>
<Button className="break-all" variant="outline">
/{key}
</Button>
</CollapsibleTrigger>
<CollapsibleContent>
<Tree item={value} os={os} />
</CollapsibleContent>
</Collapsible>
</ul>
</li>
);
}
})}
</ul>
);
}
export default function FileSystem({
list,
os,
}: {
list: string[];
os: string;
}) {
const tree = filesToTree(list);
return (
<div className="mt-8">
<Tree item={tree} os={os}></Tree>
</div>
);
}
+31
View File
@@ -0,0 +1,31 @@
import Link from "next/link";
export function NavTop() {
return (
<header className="flex flex-row justify-between items-center p-4 w-full bg-gray-900 text-white">
<h1 className="text-2xl font-bold">
<Link href="/" className="hover:text-gray-300">
entdb
</Link>
</h1>
<nav className="flex gap-4 text-sm">
<a
href="https://github.com/chichou/entdb-indexer"
target="_blank"
rel="noopener noreferrer"
className="hover:text-gray-300"
>
GitHub
</a>
<a
href="https://infosec.exchange/@codecolorist"
target="_blank"
rel="noopener noreferrer"
className="hover:text-gray-300"
>
Mastodon
</a>
</nav>
</header>
);
}
+163
View File
@@ -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<Group[]>([]);
const [highlights, setHighlights] = useState<Set<string>>(new Set());
useEffect(() => {
const set: Set<string> = 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<string, OS[]> = 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<string[]>)
.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 (
<div>
{loading && (
<div className="space-y-6">
<div className="mb-4 flex items-center">
<Skeleton className="h-4 w-4 mr-2" />
<Skeleton className="h-6 w-24" />
</div>
{[1, 2, 3].map((group) => (
<section key={group} className="my-6">
<Skeleton className="h-8 w-32 my-4" />
<div className="grid grid-cols-2 xl:grid-cols-4 gap-4">
{[1, 2, 3, 4, 5, 6, 7, 8].map((item) => (
<div key={item} className="p-4 border rounded-lg">
<div className="flex justify-between items-center">
<Skeleton className="h-6 w-24" />
<Skeleton className="h-4 w-16" />
</div>
</div>
))}
</div>
</section>
))}
</div>
)}
{!loading && groups.length === 0 && (
<div className="text-center">Failed to fetch OS list</div>
)}
{!loading && (
<header className="mb-4">
<Checkbox
id="select-all"
className="mr-2"
checked={showLess}
onCheckedChange={(checked) => setShowLess(Boolean(checked))}
/>
<label htmlFor="select-all" className="text-lg font-medium">
Show Less
</label>
</header>
)}
{groups.map((group) => (
<section key={group.name} className="my-6">
<h2 className="text-2xl font-light my-4">{group.name}</h2>
<ul className="grid grid-cols-2 xl:grid-cols-4 gap-4">
{group.list
.filter((os) => !showLess || highlights.has(os.build))
.map((os, index) => (
<li key={index} className="list-none">
<Link
href={`/os/keys?os=${group.name}/${os.version}_${os.build}`}
className="block p-4 border rounded-lg shadow-sm hover:shadow-md transition-all hover:bg-gray-50"
>
<div className="flex justify-between items-center">
<h2 className="text-lg">{os.name}</h2>
<div className="text-sm text-gray-500">{os.build}</div>
</div>
</Link>
</li>
))}
</ul>
</section>
))}
</div>
);
}
+66
View File
@@ -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<typeof AccordionPrimitive.Root>) {
return <AccordionPrimitive.Root data-slot="accordion" {...props} />
}
function AccordionItem({
className,
...props
}: React.ComponentProps<typeof AccordionPrimitive.Item>) {
return (
<AccordionPrimitive.Item
data-slot="accordion-item"
className={cn("border-b last:border-b-0", className)}
{...props}
/>
)
}
function AccordionTrigger({
className,
children,
...props
}: React.ComponentProps<typeof AccordionPrimitive.Trigger>) {
return (
<AccordionPrimitive.Header className="flex">
<AccordionPrimitive.Trigger
data-slot="accordion-trigger"
className={cn(
"focus-visible:border-ring focus-visible:ring-ring/50 flex flex-1 items-start justify-between gap-4 rounded-md py-4 text-left text-sm font-medium transition-all outline-none hover:underline focus-visible:ring-[3px] disabled:pointer-events-none disabled:opacity-50 [&[data-state=open]>svg]:rotate-180",
className
)}
{...props}
>
{children}
<ChevronDownIcon className="text-muted-foreground pointer-events-none size-4 shrink-0 translate-y-0.5 transition-transform duration-200" />
</AccordionPrimitive.Trigger>
</AccordionPrimitive.Header>
)
}
function AccordionContent({
className,
children,
...props
}: React.ComponentProps<typeof AccordionPrimitive.Content>) {
return (
<AccordionPrimitive.Content
data-slot="accordion-content"
className="data-[state=closed]:animate-accordion-up data-[state=open]:animate-accordion-down overflow-hidden text-sm"
{...props}
>
<div className={cn("pt-0 pb-4", className)}>{children}</div>
</AccordionPrimitive.Content>
)
}
export { Accordion, AccordionItem, AccordionTrigger, AccordionContent }
+46
View File
@@ -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<typeof badgeVariants> & { asChild?: boolean }) {
const Comp = asChild ? Slot : "span"
return (
<Comp
data-slot="badge"
className={cn(badgeVariants({ variant }), className)}
{...props}
/>
)
}
export { Badge, badgeVariants }
+109
View File
@@ -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 <nav aria-label="breadcrumb" data-slot="breadcrumb" {...props} />
}
function BreadcrumbList({ className, ...props }: React.ComponentProps<"ol">) {
return (
<ol
data-slot="breadcrumb-list"
className={cn(
"text-muted-foreground flex flex-wrap items-center gap-1.5 text-sm break-words sm:gap-2.5",
className
)}
{...props}
/>
)
}
function BreadcrumbItem({ className, ...props }: React.ComponentProps<"li">) {
return (
<li
data-slot="breadcrumb-item"
className={cn("inline-flex items-center gap-1.5", className)}
{...props}
/>
)
}
function BreadcrumbLink({
asChild,
className,
...props
}: React.ComponentProps<"a"> & {
asChild?: boolean
}) {
const Comp = asChild ? Slot : "a"
return (
<Comp
data-slot="breadcrumb-link"
className={cn("hover:text-foreground transition-colors", className)}
{...props}
/>
)
}
function BreadcrumbPage({ className, ...props }: React.ComponentProps<"span">) {
return (
<span
data-slot="breadcrumb-page"
role="link"
aria-disabled="true"
aria-current="page"
className={cn("text-foreground font-normal", className)}
{...props}
/>
)
}
function BreadcrumbSeparator({
children,
className,
...props
}: React.ComponentProps<"li">) {
return (
<li
data-slot="breadcrumb-separator"
role="presentation"
aria-hidden="true"
className={cn("[&>svg]:size-3.5", className)}
{...props}
>
{children ?? <ChevronRight />}
</li>
)
}
function BreadcrumbEllipsis({
className,
...props
}: React.ComponentProps<"span">) {
return (
<span
data-slot="breadcrumb-ellipsis"
role="presentation"
aria-hidden="true"
className={cn("flex size-9 items-center justify-center", className)}
{...props}
>
<MoreHorizontal className="size-4" />
<span className="sr-only">More</span>
</span>
)
}
export {
Breadcrumb,
BreadcrumbList,
BreadcrumbItem,
BreadcrumbLink,
BreadcrumbPage,
BreadcrumbSeparator,
BreadcrumbEllipsis,
}
+59
View File
@@ -0,0 +1,59 @@
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 buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-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",
{
variants: {
variant: {
default:
"bg-primary text-primary-foreground shadow-xs hover:bg-primary/90",
destructive:
"bg-destructive text-white shadow-xs hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
outline:
"border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50",
secondary:
"bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80",
ghost:
"hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default: "h-9 px-4 py-2 has-[>svg]:px-3",
sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5",
lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
icon: "size-9",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
function Button({
className,
variant,
size,
asChild = false,
...props
}: React.ComponentProps<"button"> &
VariantProps<typeof buttonVariants> & {
asChild?: boolean
}) {
const Comp = asChild ? Slot : "button"
return (
<Comp
data-slot="button"
className={cn(buttonVariants({ variant, size, className }))}
{...props}
/>
)
}
export { Button, buttonVariants }
+32
View File
@@ -0,0 +1,32 @@
"use client"
import * as React from "react"
import * as CheckboxPrimitive from "@radix-ui/react-checkbox"
import { CheckIcon } from "lucide-react"
import { cn } from "@/lib/utils"
function Checkbox({
className,
...props
}: React.ComponentProps<typeof CheckboxPrimitive.Root>) {
return (
<CheckboxPrimitive.Root
data-slot="checkbox"
className={cn(
"peer border-input dark:bg-input/30 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground dark:data-[state=checked]:bg-primary data-[state=checked]:border-primary focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive size-4 shrink-0 rounded-[4px] border shadow-xs transition-shadow outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
className
)}
{...props}
>
<CheckboxPrimitive.Indicator
data-slot="checkbox-indicator"
className="flex items-center justify-center text-current transition-none"
>
<CheckIcon className="size-3.5" />
</CheckboxPrimitive.Indicator>
</CheckboxPrimitive.Root>
)
}
export { Checkbox }
+33
View File
@@ -0,0 +1,33 @@
"use client"
import * as CollapsiblePrimitive from "@radix-ui/react-collapsible"
function Collapsible({
...props
}: React.ComponentProps<typeof CollapsiblePrimitive.Root>) {
return <CollapsiblePrimitive.Root data-slot="collapsible" {...props} />
}
function CollapsibleTrigger({
...props
}: React.ComponentProps<typeof CollapsiblePrimitive.CollapsibleTrigger>) {
return (
<CollapsiblePrimitive.CollapsibleTrigger
data-slot="collapsible-trigger"
{...props}
/>
)
}
function CollapsibleContent({
...props
}: React.ComponentProps<typeof CollapsiblePrimitive.CollapsibleContent>) {
return (
<CollapsiblePrimitive.CollapsibleContent
data-slot="collapsible-content"
{...props}
/>
)
}
export { Collapsible, CollapsibleTrigger, CollapsibleContent }
+184
View File
@@ -0,0 +1,184 @@
"use client"
import * as React from "react"
import { Command as CommandPrimitive } from "cmdk"
import { SearchIcon } from "lucide-react"
import { cn } from "@/lib/utils"
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog"
function Command({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive>) {
return (
<CommandPrimitive
data-slot="command"
className={cn(
"bg-popover text-popover-foreground flex h-full w-full flex-col overflow-hidden rounded-md",
className
)}
{...props}
/>
)
}
function CommandDialog({
title = "Command Palette",
description = "Search for a command to run...",
children,
className,
showCloseButton = true,
...props
}: React.ComponentProps<typeof Dialog> & {
title?: string
description?: string
className?: string
showCloseButton?: boolean
}) {
return (
<Dialog {...props}>
<DialogHeader className="sr-only">
<DialogTitle>{title}</DialogTitle>
<DialogDescription>{description}</DialogDescription>
</DialogHeader>
<DialogContent
className={cn("overflow-hidden p-0", className)}
showCloseButton={showCloseButton}
>
<Command className="[&_[cmdk-group-heading]]:text-muted-foreground **:data-[slot=command-input-wrapper]:h-12 [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group]]:px-2 [&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0 [&_[cmdk-input-wrapper]_svg]:h-5 [&_[cmdk-input-wrapper]_svg]:w-5 [&_[cmdk-input]]:h-12 [&_[cmdk-item]]:px-2 [&_[cmdk-item]]:py-3 [&_[cmdk-item]_svg]:h-5 [&_[cmdk-item]_svg]:w-5">
{children}
</Command>
</DialogContent>
</Dialog>
)
}
function CommandInput({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.Input>) {
return (
<div
data-slot="command-input-wrapper"
className="flex h-9 items-center gap-2 border-b px-3"
>
<SearchIcon className="size-4 shrink-0 opacity-50" />
<CommandPrimitive.Input
data-slot="command-input"
className={cn(
"placeholder:text-muted-foreground flex h-10 w-full rounded-md bg-transparent py-3 text-sm outline-hidden disabled:cursor-not-allowed disabled:opacity-50",
className
)}
{...props}
/>
</div>
)
}
function CommandList({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.List>) {
return (
<CommandPrimitive.List
data-slot="command-list"
className={cn(
"max-h-[300px] scroll-py-1 overflow-x-hidden overflow-y-auto",
className
)}
{...props}
/>
)
}
function CommandEmpty({
...props
}: React.ComponentProps<typeof CommandPrimitive.Empty>) {
return (
<CommandPrimitive.Empty
data-slot="command-empty"
className="py-6 text-center text-sm"
{...props}
/>
)
}
function CommandGroup({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.Group>) {
return (
<CommandPrimitive.Group
data-slot="command-group"
className={cn(
"text-foreground [&_[cmdk-group-heading]]:text-muted-foreground overflow-hidden p-1 [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1.5 [&_[cmdk-group-heading]]:text-xs [&_[cmdk-group-heading]]:font-medium",
className
)}
{...props}
/>
)
}
function CommandSeparator({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.Separator>) {
return (
<CommandPrimitive.Separator
data-slot="command-separator"
className={cn("bg-border -mx-1 h-px", className)}
{...props}
/>
)
}
function CommandItem({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.Item>) {
return (
<CommandPrimitive.Item
data-slot="command-item"
className={cn(
"data-[selected=true]:bg-accent data-[selected=true]:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
/>
)
}
function CommandShortcut({
className,
...props
}: React.ComponentProps<"span">) {
return (
<span
data-slot="command-shortcut"
className={cn(
"text-muted-foreground ml-auto text-xs tracking-widest",
className
)}
{...props}
/>
)
}
export {
Command,
CommandDialog,
CommandInput,
CommandList,
CommandEmpty,
CommandGroup,
CommandItem,
CommandShortcut,
CommandSeparator,
}
+143
View File
@@ -0,0 +1,143 @@
"use client"
import * as React from "react"
import * as DialogPrimitive from "@radix-ui/react-dialog"
import { XIcon } from "lucide-react"
import { cn } from "@/lib/utils"
function Dialog({
...props
}: React.ComponentProps<typeof DialogPrimitive.Root>) {
return <DialogPrimitive.Root data-slot="dialog" {...props} />
}
function DialogTrigger({
...props
}: React.ComponentProps<typeof DialogPrimitive.Trigger>) {
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />
}
function DialogPortal({
...props
}: React.ComponentProps<typeof DialogPrimitive.Portal>) {
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />
}
function DialogClose({
...props
}: React.ComponentProps<typeof DialogPrimitive.Close>) {
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />
}
function DialogOverlay({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Overlay>) {
return (
<DialogPrimitive.Overlay
data-slot="dialog-overlay"
className={cn(
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
className
)}
{...props}
/>
)
}
function DialogContent({
className,
children,
showCloseButton = true,
...props
}: React.ComponentProps<typeof DialogPrimitive.Content> & {
showCloseButton?: boolean
}) {
return (
<DialogPortal data-slot="dialog-portal">
<DialogOverlay />
<DialogPrimitive.Content
data-slot="dialog-content"
className={cn(
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg",
className
)}
{...props}
>
{children}
{showCloseButton && (
<DialogPrimitive.Close
data-slot="dialog-close"
className="ring-offset-background focus:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4"
>
<XIcon />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
)}
</DialogPrimitive.Content>
</DialogPortal>
)
}
function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="dialog-header"
className={cn("flex flex-col gap-2 text-center sm:text-left", className)}
{...props}
/>
)
}
function DialogFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="dialog-footer"
className={cn(
"flex flex-col-reverse gap-2 sm:flex-row sm:justify-end",
className
)}
{...props}
/>
)
}
function DialogTitle({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Title>) {
return (
<DialogPrimitive.Title
data-slot="dialog-title"
className={cn("text-lg leading-none font-semibold", className)}
{...props}
/>
)
}
function DialogDescription({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Description>) {
return (
<DialogPrimitive.Description
data-slot="dialog-description"
className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
)
}
export {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogOverlay,
DialogPortal,
DialogTitle,
DialogTrigger,
}
+21
View File
@@ -0,0 +1,21 @@
import * as React from "react"
import { cn } from "@/lib/utils"
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
return (
<input
type={type}
data-slot="input"
className={cn(
"file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input flex h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
"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",
className
)}
{...props}
/>
)
}
export { Input }
+48
View File
@@ -0,0 +1,48 @@
"use client"
import * as React from "react"
import * as PopoverPrimitive from "@radix-ui/react-popover"
import { cn } from "@/lib/utils"
function Popover({
...props
}: React.ComponentProps<typeof PopoverPrimitive.Root>) {
return <PopoverPrimitive.Root data-slot="popover" {...props} />
}
function PopoverTrigger({
...props
}: React.ComponentProps<typeof PopoverPrimitive.Trigger>) {
return <PopoverPrimitive.Trigger data-slot="popover-trigger" {...props} />
}
function PopoverContent({
className,
align = "center",
sideOffset = 4,
...props
}: React.ComponentProps<typeof PopoverPrimitive.Content>) {
return (
<PopoverPrimitive.Portal>
<PopoverPrimitive.Content
data-slot="popover-content"
align={align}
sideOffset={sideOffset}
className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-72 origin-(--radix-popover-content-transform-origin) rounded-md border p-4 shadow-md outline-hidden",
className
)}
{...props}
/>
</PopoverPrimitive.Portal>
)
}
function PopoverAnchor({
...props
}: React.ComponentProps<typeof PopoverPrimitive.Anchor>) {
return <PopoverPrimitive.Anchor data-slot="popover-anchor" {...props} />
}
export { Popover, PopoverTrigger, PopoverContent, PopoverAnchor }
+28
View File
@@ -0,0 +1,28 @@
"use client"
import * as React from "react"
import * as SeparatorPrimitive from "@radix-ui/react-separator"
import { cn } from "@/lib/utils"
function Separator({
className,
orientation = "horizontal",
decorative = true,
...props
}: React.ComponentProps<typeof SeparatorPrimitive.Root>) {
return (
<SeparatorPrimitive.Root
data-slot="separator"
decorative={decorative}
orientation={orientation}
className={cn(
"bg-border shrink-0 data-[orientation=horizontal]:h-px data-[orientation=horizontal]:w-full data-[orientation=vertical]:h-full data-[orientation=vertical]:w-px",
className
)}
{...props}
/>
)
}
export { Separator }
+139
View File
@@ -0,0 +1,139 @@
"use client"
import * as React from "react"
import * as SheetPrimitive from "@radix-ui/react-dialog"
import { XIcon } from "lucide-react"
import { cn } from "@/lib/utils"
function Sheet({ ...props }: React.ComponentProps<typeof SheetPrimitive.Root>) {
return <SheetPrimitive.Root data-slot="sheet" {...props} />
}
function SheetTrigger({
...props
}: React.ComponentProps<typeof SheetPrimitive.Trigger>) {
return <SheetPrimitive.Trigger data-slot="sheet-trigger" {...props} />
}
function SheetClose({
...props
}: React.ComponentProps<typeof SheetPrimitive.Close>) {
return <SheetPrimitive.Close data-slot="sheet-close" {...props} />
}
function SheetPortal({
...props
}: React.ComponentProps<typeof SheetPrimitive.Portal>) {
return <SheetPrimitive.Portal data-slot="sheet-portal" {...props} />
}
function SheetOverlay({
className,
...props
}: React.ComponentProps<typeof SheetPrimitive.Overlay>) {
return (
<SheetPrimitive.Overlay
data-slot="sheet-overlay"
className={cn(
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
className
)}
{...props}
/>
)
}
function SheetContent({
className,
children,
side = "right",
...props
}: React.ComponentProps<typeof SheetPrimitive.Content> & {
side?: "top" | "right" | "bottom" | "left"
}) {
return (
<SheetPortal>
<SheetOverlay />
<SheetPrimitive.Content
data-slot="sheet-content"
className={cn(
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out fixed z-50 flex flex-col gap-4 shadow-lg transition ease-in-out data-[state=closed]:duration-300 data-[state=open]:duration-500",
side === "right" &&
"data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right inset-y-0 right-0 h-full w-3/4 border-l sm:max-w-sm",
side === "left" &&
"data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left inset-y-0 left-0 h-full w-3/4 border-r sm:max-w-sm",
side === "top" &&
"data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top inset-x-0 top-0 h-auto border-b",
side === "bottom" &&
"data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom inset-x-0 bottom-0 h-auto border-t",
className
)}
{...props}
>
{children}
<SheetPrimitive.Close className="ring-offset-background focus:ring-ring data-[state=open]:bg-secondary absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none">
<XIcon className="size-4" />
<span className="sr-only">Close</span>
</SheetPrimitive.Close>
</SheetPrimitive.Content>
</SheetPortal>
)
}
function SheetHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sheet-header"
className={cn("flex flex-col gap-1.5 p-4", className)}
{...props}
/>
)
}
function SheetFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sheet-footer"
className={cn("mt-auto flex flex-col gap-2 p-4", className)}
{...props}
/>
)
}
function SheetTitle({
className,
...props
}: React.ComponentProps<typeof SheetPrimitive.Title>) {
return (
<SheetPrimitive.Title
data-slot="sheet-title"
className={cn("text-foreground font-semibold", className)}
{...props}
/>
)
}
function SheetDescription({
className,
...props
}: React.ComponentProps<typeof SheetPrimitive.Description>) {
return (
<SheetPrimitive.Description
data-slot="sheet-description"
className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
)
}
export {
Sheet,
SheetTrigger,
SheetClose,
SheetContent,
SheetHeader,
SheetFooter,
SheetTitle,
SheetDescription,
}
+726
View File
@@ -0,0 +1,726 @@
"use client"
import * as React from "react"
import { Slot } from "@radix-ui/react-slot"
import { cva, VariantProps } from "class-variance-authority"
import { PanelLeftIcon } from "lucide-react"
import { useIsMobile } from "@/hooks/use-mobile"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Separator } from "@/components/ui/separator"
import {
Sheet,
SheetContent,
SheetDescription,
SheetHeader,
SheetTitle,
} from "@/components/ui/sheet"
import { Skeleton } from "@/components/ui/skeleton"
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip"
const SIDEBAR_COOKIE_NAME = "sidebar_state"
const SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 7
const SIDEBAR_WIDTH = "16rem"
const SIDEBAR_WIDTH_MOBILE = "18rem"
const SIDEBAR_WIDTH_ICON = "3rem"
const SIDEBAR_KEYBOARD_SHORTCUT = "b"
type SidebarContextProps = {
state: "expanded" | "collapsed"
open: boolean
setOpen: (open: boolean) => void
openMobile: boolean
setOpenMobile: (open: boolean) => void
isMobile: boolean
toggleSidebar: () => void
}
const SidebarContext = React.createContext<SidebarContextProps | null>(null)
function useSidebar() {
const context = React.useContext(SidebarContext)
if (!context) {
throw new Error("useSidebar must be used within a SidebarProvider.")
}
return context
}
function SidebarProvider({
defaultOpen = true,
open: openProp,
onOpenChange: setOpenProp,
className,
style,
children,
...props
}: React.ComponentProps<"div"> & {
defaultOpen?: boolean
open?: boolean
onOpenChange?: (open: boolean) => void
}) {
const isMobile = useIsMobile()
const [openMobile, setOpenMobile] = React.useState(false)
// This is the internal state of the sidebar.
// We use openProp and setOpenProp for control from outside the component.
const [_open, _setOpen] = React.useState(defaultOpen)
const open = openProp ?? _open
const setOpen = React.useCallback(
(value: boolean | ((value: boolean) => boolean)) => {
const openState = typeof value === "function" ? value(open) : value
if (setOpenProp) {
setOpenProp(openState)
} else {
_setOpen(openState)
}
// This sets the cookie to keep the sidebar state.
document.cookie = `${SIDEBAR_COOKIE_NAME}=${openState}; path=/; max-age=${SIDEBAR_COOKIE_MAX_AGE}`
},
[setOpenProp, open]
)
// Helper to toggle the sidebar.
const toggleSidebar = React.useCallback(() => {
return isMobile ? setOpenMobile((open) => !open) : setOpen((open) => !open)
}, [isMobile, setOpen, setOpenMobile])
// Adds a keyboard shortcut to toggle the sidebar.
React.useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
if (
event.key === SIDEBAR_KEYBOARD_SHORTCUT &&
(event.metaKey || event.ctrlKey)
) {
event.preventDefault()
toggleSidebar()
}
}
window.addEventListener("keydown", handleKeyDown)
return () => window.removeEventListener("keydown", handleKeyDown)
}, [toggleSidebar])
// We add a state so that we can do data-state="expanded" or "collapsed".
// This makes it easier to style the sidebar with Tailwind classes.
const state = open ? "expanded" : "collapsed"
const contextValue = React.useMemo<SidebarContextProps>(
() => ({
state,
open,
setOpen,
isMobile,
openMobile,
setOpenMobile,
toggleSidebar,
}),
[state, open, setOpen, isMobile, openMobile, setOpenMobile, toggleSidebar]
)
return (
<SidebarContext.Provider value={contextValue}>
<TooltipProvider delayDuration={0}>
<div
data-slot="sidebar-wrapper"
style={
{
"--sidebar-width": SIDEBAR_WIDTH,
"--sidebar-width-icon": SIDEBAR_WIDTH_ICON,
...style,
} as React.CSSProperties
}
className={cn(
"group/sidebar-wrapper has-data-[variant=inset]:bg-sidebar flex min-h-svh w-full",
className
)}
{...props}
>
{children}
</div>
</TooltipProvider>
</SidebarContext.Provider>
)
}
function Sidebar({
side = "left",
variant = "sidebar",
collapsible = "offcanvas",
className,
children,
...props
}: React.ComponentProps<"div"> & {
side?: "left" | "right"
variant?: "sidebar" | "floating" | "inset"
collapsible?: "offcanvas" | "icon" | "none"
}) {
const { isMobile, state, openMobile, setOpenMobile } = useSidebar()
if (collapsible === "none") {
return (
<div
data-slot="sidebar"
className={cn(
"bg-sidebar text-sidebar-foreground flex h-full w-(--sidebar-width) flex-col",
className
)}
{...props}
>
{children}
</div>
)
}
if (isMobile) {
return (
<Sheet open={openMobile} onOpenChange={setOpenMobile} {...props}>
<SheetContent
data-sidebar="sidebar"
data-slot="sidebar"
data-mobile="true"
className="bg-sidebar text-sidebar-foreground w-(--sidebar-width) p-0 [&>button]:hidden"
style={
{
"--sidebar-width": SIDEBAR_WIDTH_MOBILE,
} as React.CSSProperties
}
side={side}
>
<SheetHeader className="sr-only">
<SheetTitle>Sidebar</SheetTitle>
<SheetDescription>Displays the mobile sidebar.</SheetDescription>
</SheetHeader>
<div className="flex h-full w-full flex-col">{children}</div>
</SheetContent>
</Sheet>
)
}
return (
<div
className="group peer text-sidebar-foreground hidden md:block"
data-state={state}
data-collapsible={state === "collapsed" ? collapsible : ""}
data-variant={variant}
data-side={side}
data-slot="sidebar"
>
{/* This is what handles the sidebar gap on desktop */}
<div
data-slot="sidebar-gap"
className={cn(
"relative w-(--sidebar-width) bg-transparent transition-[width] duration-200 ease-linear",
"group-data-[collapsible=offcanvas]:w-0",
"group-data-[side=right]:rotate-180",
variant === "floating" || variant === "inset"
? "group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+(--spacing(4)))]"
: "group-data-[collapsible=icon]:w-(--sidebar-width-icon)"
)}
/>
<div
data-slot="sidebar-container"
className={cn(
"fixed inset-y-0 z-10 hidden h-svh w-(--sidebar-width) transition-[left,right,width] duration-200 ease-linear md:flex",
side === "left"
? "left-0 group-data-[collapsible=offcanvas]:left-[calc(var(--sidebar-width)*-1)]"
: "right-0 group-data-[collapsible=offcanvas]:right-[calc(var(--sidebar-width)*-1)]",
// Adjust the padding for floating and inset variants.
variant === "floating" || variant === "inset"
? "p-2 group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+(--spacing(4))+2px)]"
: "group-data-[collapsible=icon]:w-(--sidebar-width-icon) group-data-[side=left]:border-r group-data-[side=right]:border-l",
className
)}
{...props}
>
<div
data-sidebar="sidebar"
data-slot="sidebar-inner"
className="bg-sidebar group-data-[variant=floating]:border-sidebar-border flex h-full w-full flex-col group-data-[variant=floating]:rounded-lg group-data-[variant=floating]:border group-data-[variant=floating]:shadow-sm"
>
{children}
</div>
</div>
</div>
)
}
function SidebarTrigger({
className,
onClick,
...props
}: React.ComponentProps<typeof Button>) {
const { toggleSidebar } = useSidebar()
return (
<Button
data-sidebar="trigger"
data-slot="sidebar-trigger"
variant="ghost"
size="icon"
className={cn("size-7", className)}
onClick={(event) => {
onClick?.(event)
toggleSidebar()
}}
{...props}
>
<PanelLeftIcon />
<span className="sr-only">Toggle Sidebar</span>
</Button>
)
}
function SidebarRail({ className, ...props }: React.ComponentProps<"button">) {
const { toggleSidebar } = useSidebar()
return (
<button
data-sidebar="rail"
data-slot="sidebar-rail"
aria-label="Toggle Sidebar"
tabIndex={-1}
onClick={toggleSidebar}
title="Toggle Sidebar"
className={cn(
"hover:after:bg-sidebar-border absolute inset-y-0 z-20 hidden w-4 -translate-x-1/2 transition-all ease-linear group-data-[side=left]:-right-4 group-data-[side=right]:left-0 after:absolute after:inset-y-0 after:left-1/2 after:w-[2px] sm:flex",
"in-data-[side=left]:cursor-w-resize in-data-[side=right]:cursor-e-resize",
"[[data-side=left][data-state=collapsed]_&]:cursor-e-resize [[data-side=right][data-state=collapsed]_&]:cursor-w-resize",
"hover:group-data-[collapsible=offcanvas]:bg-sidebar group-data-[collapsible=offcanvas]:translate-x-0 group-data-[collapsible=offcanvas]:after:left-full",
"[[data-side=left][data-collapsible=offcanvas]_&]:-right-2",
"[[data-side=right][data-collapsible=offcanvas]_&]:-left-2",
className
)}
{...props}
/>
)
}
function SidebarInset({ className, ...props }: React.ComponentProps<"main">) {
return (
<main
data-slot="sidebar-inset"
className={cn(
"bg-background relative flex w-full flex-1 flex-col",
"md:peer-data-[variant=inset]:m-2 md:peer-data-[variant=inset]:ml-0 md:peer-data-[variant=inset]:rounded-xl md:peer-data-[variant=inset]:shadow-sm md:peer-data-[variant=inset]:peer-data-[state=collapsed]:ml-2",
className
)}
{...props}
/>
)
}
function SidebarInput({
className,
...props
}: React.ComponentProps<typeof Input>) {
return (
<Input
data-slot="sidebar-input"
data-sidebar="input"
className={cn("bg-background h-8 w-full shadow-none", className)}
{...props}
/>
)
}
function SidebarHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sidebar-header"
data-sidebar="header"
className={cn("flex flex-col gap-2 p-2", className)}
{...props}
/>
)
}
function SidebarFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sidebar-footer"
data-sidebar="footer"
className={cn("flex flex-col gap-2 p-2", className)}
{...props}
/>
)
}
function SidebarSeparator({
className,
...props
}: React.ComponentProps<typeof Separator>) {
return (
<Separator
data-slot="sidebar-separator"
data-sidebar="separator"
className={cn("bg-sidebar-border mx-2 w-auto", className)}
{...props}
/>
)
}
function SidebarContent({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sidebar-content"
data-sidebar="content"
className={cn(
"flex min-h-0 flex-1 flex-col gap-2 overflow-auto group-data-[collapsible=icon]:overflow-hidden",
className
)}
{...props}
/>
)
}
function SidebarGroup({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sidebar-group"
data-sidebar="group"
className={cn("relative flex w-full min-w-0 flex-col p-2", className)}
{...props}
/>
)
}
function SidebarGroupLabel({
className,
asChild = false,
...props
}: React.ComponentProps<"div"> & { asChild?: boolean }) {
const Comp = asChild ? Slot : "div"
return (
<Comp
data-slot="sidebar-group-label"
data-sidebar="group-label"
className={cn(
"text-sidebar-foreground/70 ring-sidebar-ring flex h-8 shrink-0 items-center rounded-md px-2 text-xs font-medium outline-hidden transition-[margin,opacity] duration-200 ease-linear focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
"group-data-[collapsible=icon]:-mt-8 group-data-[collapsible=icon]:opacity-0",
className
)}
{...props}
/>
)
}
function SidebarGroupAction({
className,
asChild = false,
...props
}: React.ComponentProps<"button"> & { asChild?: boolean }) {
const Comp = asChild ? Slot : "button"
return (
<Comp
data-slot="sidebar-group-action"
data-sidebar="group-action"
className={cn(
"text-sidebar-foreground ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground absolute top-3.5 right-3 flex aspect-square w-5 items-center justify-center rounded-md p-0 outline-hidden transition-transform focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
// Increases the hit area of the button on mobile.
"after:absolute after:-inset-2 md:after:hidden",
"group-data-[collapsible=icon]:hidden",
className
)}
{...props}
/>
)
}
function SidebarGroupContent({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="sidebar-group-content"
data-sidebar="group-content"
className={cn("w-full text-sm", className)}
{...props}
/>
)
}
function SidebarMenu({ className, ...props }: React.ComponentProps<"ul">) {
return (
<ul
data-slot="sidebar-menu"
data-sidebar="menu"
className={cn("flex w-full min-w-0 flex-col gap-1", className)}
{...props}
/>
)
}
function SidebarMenuItem({ className, ...props }: React.ComponentProps<"li">) {
return (
<li
data-slot="sidebar-menu-item"
data-sidebar="menu-item"
className={cn("group/menu-item relative", className)}
{...props}
/>
)
}
const sidebarMenuButtonVariants = cva(
"peer/menu-button flex w-full items-center gap-2 overflow-hidden rounded-md p-2 text-left text-sm outline-hidden ring-sidebar-ring transition-[width,height,padding] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 group-has-data-[sidebar=menu-action]/menu-item:pr-8 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-[active=true]:bg-sidebar-accent data-[active=true]:font-medium data-[active=true]:text-sidebar-accent-foreground data-[state=open]:hover:bg-sidebar-accent data-[state=open]:hover:text-sidebar-accent-foreground group-data-[collapsible=icon]:size-8! group-data-[collapsible=icon]:p-2! [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0",
{
variants: {
variant: {
default: "hover:bg-sidebar-accent hover:text-sidebar-accent-foreground",
outline:
"bg-background shadow-[0_0_0_1px_hsl(var(--sidebar-border))] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground hover:shadow-[0_0_0_1px_hsl(var(--sidebar-accent))]",
},
size: {
default: "h-8 text-sm",
sm: "h-7 text-xs",
lg: "h-12 text-sm group-data-[collapsible=icon]:p-0!",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
function SidebarMenuButton({
asChild = false,
isActive = false,
variant = "default",
size = "default",
tooltip,
className,
...props
}: React.ComponentProps<"button"> & {
asChild?: boolean
isActive?: boolean
tooltip?: string | React.ComponentProps<typeof TooltipContent>
} & VariantProps<typeof sidebarMenuButtonVariants>) {
const Comp = asChild ? Slot : "button"
const { isMobile, state } = useSidebar()
const button = (
<Comp
data-slot="sidebar-menu-button"
data-sidebar="menu-button"
data-size={size}
data-active={isActive}
className={cn(sidebarMenuButtonVariants({ variant, size }), className)}
{...props}
/>
)
if (!tooltip) {
return button
}
if (typeof tooltip === "string") {
tooltip = {
children: tooltip,
}
}
return (
<Tooltip>
<TooltipTrigger asChild>{button}</TooltipTrigger>
<TooltipContent
side="right"
align="center"
hidden={state !== "collapsed" || isMobile}
{...tooltip}
/>
</Tooltip>
)
}
function SidebarMenuAction({
className,
asChild = false,
showOnHover = false,
...props
}: React.ComponentProps<"button"> & {
asChild?: boolean
showOnHover?: boolean
}) {
const Comp = asChild ? Slot : "button"
return (
<Comp
data-slot="sidebar-menu-action"
data-sidebar="menu-action"
className={cn(
"text-sidebar-foreground ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground peer-hover/menu-button:text-sidebar-accent-foreground absolute top-1.5 right-1 flex aspect-square w-5 items-center justify-center rounded-md p-0 outline-hidden transition-transform focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
// Increases the hit area of the button on mobile.
"after:absolute after:-inset-2 md:after:hidden",
"peer-data-[size=sm]/menu-button:top-1",
"peer-data-[size=default]/menu-button:top-1.5",
"peer-data-[size=lg]/menu-button:top-2.5",
"group-data-[collapsible=icon]:hidden",
showOnHover &&
"peer-data-[active=true]/menu-button:text-sidebar-accent-foreground group-focus-within/menu-item:opacity-100 group-hover/menu-item:opacity-100 data-[state=open]:opacity-100 md:opacity-0",
className
)}
{...props}
/>
)
}
function SidebarMenuBadge({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="sidebar-menu-badge"
data-sidebar="menu-badge"
className={cn(
"text-sidebar-foreground pointer-events-none absolute right-1 flex h-5 min-w-5 items-center justify-center rounded-md px-1 text-xs font-medium tabular-nums select-none",
"peer-hover/menu-button:text-sidebar-accent-foreground peer-data-[active=true]/menu-button:text-sidebar-accent-foreground",
"peer-data-[size=sm]/menu-button:top-1",
"peer-data-[size=default]/menu-button:top-1.5",
"peer-data-[size=lg]/menu-button:top-2.5",
"group-data-[collapsible=icon]:hidden",
className
)}
{...props}
/>
)
}
function SidebarMenuSkeleton({
className,
showIcon = false,
...props
}: React.ComponentProps<"div"> & {
showIcon?: boolean
}) {
// Random width between 50 to 90%.
const width = React.useMemo(() => {
return `${Math.floor(Math.random() * 40) + 50}%`
}, [])
return (
<div
data-slot="sidebar-menu-skeleton"
data-sidebar="menu-skeleton"
className={cn("flex h-8 items-center gap-2 rounded-md px-2", className)}
{...props}
>
{showIcon && (
<Skeleton
className="size-4 rounded-md"
data-sidebar="menu-skeleton-icon"
/>
)}
<Skeleton
className="h-4 max-w-(--skeleton-width) flex-1"
data-sidebar="menu-skeleton-text"
style={
{
"--skeleton-width": width,
} as React.CSSProperties
}
/>
</div>
)
}
function SidebarMenuSub({ className, ...props }: React.ComponentProps<"ul">) {
return (
<ul
data-slot="sidebar-menu-sub"
data-sidebar="menu-sub"
className={cn(
"border-sidebar-border mx-3.5 flex min-w-0 translate-x-px flex-col gap-1 border-l px-2.5 py-0.5",
"group-data-[collapsible=icon]:hidden",
className
)}
{...props}
/>
)
}
function SidebarMenuSubItem({
className,
...props
}: React.ComponentProps<"li">) {
return (
<li
data-slot="sidebar-menu-sub-item"
data-sidebar="menu-sub-item"
className={cn("group/menu-sub-item relative", className)}
{...props}
/>
)
}
function SidebarMenuSubButton({
asChild = false,
size = "md",
isActive = false,
className,
...props
}: React.ComponentProps<"a"> & {
asChild?: boolean
size?: "sm" | "md"
isActive?: boolean
}) {
const Comp = asChild ? Slot : "a"
return (
<Comp
data-slot="sidebar-menu-sub-button"
data-sidebar="menu-sub-button"
data-size={size}
data-active={isActive}
className={cn(
"text-sidebar-foreground ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground active:bg-sidebar-accent active:text-sidebar-accent-foreground [&>svg]:text-sidebar-accent-foreground flex h-7 min-w-0 -translate-x-px items-center gap-2 overflow-hidden rounded-md px-2 outline-hidden focus-visible:ring-2 disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0",
"data-[active=true]:bg-sidebar-accent data-[active=true]:text-sidebar-accent-foreground",
size === "sm" && "text-xs",
size === "md" && "text-sm",
"group-data-[collapsible=icon]:hidden",
className
)}
{...props}
/>
)
}
export {
Sidebar,
SidebarContent,
SidebarFooter,
SidebarGroup,
SidebarGroupAction,
SidebarGroupContent,
SidebarGroupLabel,
SidebarHeader,
SidebarInput,
SidebarInset,
SidebarMenu,
SidebarMenuAction,
SidebarMenuBadge,
SidebarMenuButton,
SidebarMenuItem,
SidebarMenuSkeleton,
SidebarMenuSub,
SidebarMenuSubButton,
SidebarMenuSubItem,
SidebarProvider,
SidebarRail,
SidebarSeparator,
SidebarTrigger,
useSidebar,
}
+13
View File
@@ -0,0 +1,13 @@
import { cn } from "@/lib/utils"
function Skeleton({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="skeleton"
className={cn("bg-accent animate-pulse rounded-md", className)}
{...props}
/>
)
}
export { Skeleton }
+25
View File
@@ -0,0 +1,25 @@
"use client"
import { useTheme } from "next-themes"
import { Toaster as Sonner, ToasterProps } from "sonner"
const Toaster = ({ ...props }: ToasterProps) => {
const { theme = "system" } = useTheme()
return (
<Sonner
theme={theme as ToasterProps["theme"]}
className="toaster group"
style={
{
"--normal-bg": "var(--popover)",
"--normal-text": "var(--popover-foreground)",
"--normal-border": "var(--border)",
} as React.CSSProperties
}
{...props}
/>
)
}
export { Toaster }
+66
View File
@@ -0,0 +1,66 @@
"use client"
import * as React from "react"
import * as TabsPrimitive from "@radix-ui/react-tabs"
import { cn } from "@/lib/utils"
function Tabs({
className,
...props
}: React.ComponentProps<typeof TabsPrimitive.Root>) {
return (
<TabsPrimitive.Root
data-slot="tabs"
className={cn("flex flex-col gap-2", className)}
{...props}
/>
)
}
function TabsList({
className,
...props
}: React.ComponentProps<typeof TabsPrimitive.List>) {
return (
<TabsPrimitive.List
data-slot="tabs-list"
className={cn(
"bg-muted text-muted-foreground inline-flex h-9 w-fit items-center justify-center rounded-lg p-[3px]",
className
)}
{...props}
/>
)
}
function TabsTrigger({
className,
...props
}: React.ComponentProps<typeof TabsPrimitive.Trigger>) {
return (
<TabsPrimitive.Trigger
data-slot="tabs-trigger"
className={cn(
"data-[state=active]:bg-background dark:data-[state=active]:text-foreground focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:outline-ring dark:data-[state=active]:border-input dark:data-[state=active]:bg-input/30 text-foreground dark:text-muted-foreground inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 rounded-md border border-transparent px-2 py-1 text-sm font-medium whitespace-nowrap transition-[color,box-shadow] focus-visible:ring-[3px] focus-visible:outline-1 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:shadow-sm [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
/>
)
}
function TabsContent({
className,
...props
}: React.ComponentProps<typeof TabsPrimitive.Content>) {
return (
<TabsPrimitive.Content
data-slot="tabs-content"
className={cn("flex-1 outline-none", className)}
{...props}
/>
)
}
export { Tabs, TabsList, TabsTrigger, TabsContent }
+61
View File
@@ -0,0 +1,61 @@
"use client"
import * as React from "react"
import * as TooltipPrimitive from "@radix-ui/react-tooltip"
import { cn } from "@/lib/utils"
function TooltipProvider({
delayDuration = 0,
...props
}: React.ComponentProps<typeof TooltipPrimitive.Provider>) {
return (
<TooltipPrimitive.Provider
data-slot="tooltip-provider"
delayDuration={delayDuration}
{...props}
/>
)
}
function Tooltip({
...props
}: React.ComponentProps<typeof TooltipPrimitive.Root>) {
return (
<TooltipProvider>
<TooltipPrimitive.Root data-slot="tooltip" {...props} />
</TooltipProvider>
)
}
function TooltipTrigger({
...props
}: React.ComponentProps<typeof TooltipPrimitive.Trigger>) {
return <TooltipPrimitive.Trigger data-slot="tooltip-trigger" {...props} />
}
function TooltipContent({
className,
sideOffset = 0,
children,
...props
}: React.ComponentProps<typeof TooltipPrimitive.Content>) {
return (
<TooltipPrimitive.Portal>
<TooltipPrimitive.Content
data-slot="tooltip-content"
sideOffset={sideOffset}
className={cn(
"bg-primary text-primary-foreground animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-fit origin-(--radix-tooltip-content-transform-origin) rounded-md px-3 py-1.5 text-xs text-balance",
className
)}
{...props}
>
{children}
<TooltipPrimitive.Arrow className="bg-primary fill-primary z-50 size-2.5 translate-y-[calc(-50%_-_2px)] rotate-45 rounded-[2px]" />
</TooltipPrimitive.Content>
</TooltipPrimitive.Portal>
)
}
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }
+19
View File
@@ -0,0 +1,19 @@
import * as React from "react"
const MOBILE_BREAKPOINT = 768
export function useIsMobile() {
const [isMobile, setIsMobile] = React.useState<boolean | undefined>(undefined)
React.useEffect(() => {
const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`)
const onChange = () => {
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT)
}
mql.addEventListener("change", onChange)
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT)
return () => mql.removeEventListener("change", onChange)
}, [])
return !!isMobile
}
+15
View File
@@ -0,0 +1,15 @@
export async function fetchText(url: string | URL): Promise<string> {
const r = await fetch(url);
if (!r.ok) {
throw new Error(`Failed to fetch ${url}: ${r.status} ${r.statusText}`);
}
return r.text();
}
function splitLines(text: string): string[] {
return text.split(/\r?\n/).filter((l) => l.trim().length > 0);
}
export async function fetchLines(url: string | URL): Promise<string[]> {
return splitLines(await fetchText(url));
}
+36
View File
@@ -0,0 +1,36 @@
import type { Engine } from "./types";
import { WASMEngine } from "./wasm";
import { KVEngine } from "./kv";
let wasmSupported: boolean | null = null;
async function checkWASMSupport(): Promise<boolean> {
if (wasmSupported !== null) return wasmSupported;
try {
if (typeof WebAssembly === "undefined") {
wasmSupported = false;
return false;
}
await WebAssembly.instantiate(Uint8Array.of(0x0, 0x61, 0x73, 0x6d, 0x01, 0x00, 0x00, 0x00));
await import("@sqlite.org/sqlite-wasm");
wasmSupported = true;
return true;
} catch {
wasmSupported = false;
return false;
}
}
export async function createEngine(group: string): Promise<Engine> {
const supported = await checkWASMSupport();
if (supported) {
return new WASMEngine();
}
return new KVEngine(group);
}
export { KVEngine, WASMEngine };
+113
View File
@@ -0,0 +1,113 @@
import type { Engine } from "./types";
import type { OS } from "@/lib/types";
import { addBasePath, dataBaseURL } from "@/lib/env";
import { fetchText, fetchLines } from "@/lib/client";
interface KVRecord {
key: string;
offset: number;
length: number;
}
class KVStore {
#index: Map<string, [number, number]> = new Map();
#blobsURL: string;
constructor(records: KVRecord[], blobsURL: string) {
for (const { key, offset, length } of records) {
if (this.#index.has(key)) {
throw new Error(`invalid data source, duplicate key: ${key}`);
}
this.#index.set(key, [offset, length]);
}
this.#blobsURL = blobsURL;
}
async get(key: string): Promise<string> {
const pair = this.#index.get(key);
if (!pair) {
throw new Error(`key not found: ${key}`);
}
const [offset, length] = pair;
return fetch(this.#blobsURL, {
headers: {
Range: `bytes=${offset}-${offset + length - 1}`,
},
}).then((r) => {
if (!r.ok) {
throw new Error(`failed to fetch blob for key: ${key}`);
}
return r.text();
});
}
*keys(): IterableIterator<string> {
yield* this.#index.keys();
}
}
export class KVEngine implements Engine {
#baseURL: string;
constructor(group: string) {
this.#baseURL = `${dataBaseURL()}/${group}`;
}
async listOS(): Promise<OS[]> {
const list = await fetchText(addBasePath(`${this.#baseURL}/list.json`));
return JSON.parse(list);
}
async getPaths(build: string): Promise<string[]> {
const os = await this.findOS(build);
const tag = `${os.version}_${build}`;
return fetchLines(addBasePath(`${this.#baseURL}/${tag}/paths.txt`));
}
async getBinaryXML(build: string, path: string): Promise<string> {
const os = await this.findOS(build);
const tag = `${os.version}_${build}`;
const reader = await this.openKV(`${this.#baseURL}/${tag}/blobs`);
const blob = await reader.get(path);
const location = blob.search(/<\/plist>\s*{/i);
if (location === -1) {
return blob;
}
return blob.substring(0, location + 8);
}
async getKeys(build: string): Promise<string[]> {
const os = await this.findOS(build);
const tag = `${os.version}_${build}`;
const reader = await this.openKV(`${this.#baseURL}/${tag}/keys`);
return [...reader.keys()];
}
async getPathsForKey(build: string, key: string): Promise<string[]> {
const os = await this.findOS(build);
const tag = `${os.version}_${build}`;
const reader = await this.openKV(`${this.#baseURL}/${tag}/keys`);
const lines = await reader.get(key);
return lines.split("\n").filter(Boolean);
}
#osCache: OS[] | null = null;
private async findOS(build: string): Promise<OS> {
if (!this.#osCache) {
this.#osCache = await this.listOS();
}
const os = this.#osCache.find((o) => o.build === build);
if (!os) throw new Error(`OS not found for build: ${build}`);
return os;
}
private async openKV(baseURL: string): Promise<KVStore> {
const recordsURL = baseURL + ".index.json";
const blobsURL = baseURL + ".txt";
const records = await fetch(recordsURL).then((r) => r.json());
return new KVStore(records, blobsURL);
}
}
+9
View File
@@ -0,0 +1,9 @@
import type { OS } from "@/lib/types";
export interface Engine {
listOS(): Promise<OS[]>;
getPaths(build: string): Promise<string[]>;
getBinaryXML(build: string, path: string): Promise<string>;
getKeys(build: string): Promise<string[]>;
getPathsForKey(build: string, key: string): Promise<string[]>;
}
+106
View File
@@ -0,0 +1,106 @@
import type { Engine } from "./types";
import type { OS } from "@/lib/types";
import { dataBaseURL } from "@/lib/env";
type SQLite3API = {
Database: new (data: ArrayLike<number | bigint>) => {
exec: (
sql: string,
bind?: unknown[]
) => {
columns: string[];
rows: unknown[][];
}[];
close: () => void;
};
};
let sqlite3Module: SQLite3API | null = null;
let dbInstance: InstanceType<SQLite3API["Database"]> | null = null;
let dbReady = false;
async function loadSQLite(): Promise<SQLite3API> {
if (sqlite3Module) return sqlite3Module;
const module = await import("@sqlite.org/sqlite-wasm");
sqlite3Module = module.default || module;
return sqlite3Module;
}
async function getDB(): Promise<InstanceType<SQLite3API["Database"]>> {
if (dbReady && dbInstance) return dbInstance;
const sqlite3 = await loadSQLite();
const response = await fetch(`${dataBaseURL()}/ent.db`);
const buffer = await response.arrayBuffer();
dbInstance = new sqlite3.Database(new Uint8Array(buffer));
dbReady = true;
return dbInstance;
}
export class WASMEngine implements Engine {
async listOS(): Promise<OS[]> {
const db = await getDB();
const results = db.exec(
"SELECT name, version, build, devices FROM os ORDER BY version DESC"
);
if (!results.length) return [];
const cols = results[0].columns;
const nameIdx = cols.indexOf("name");
const versionIdx = cols.indexOf("version");
const buildIdx = cols.indexOf("build");
const devicesIdx = cols.indexOf("devices");
return results[0].rows.map((row) => ({
name: row[nameIdx] as string,
version: row[versionIdx] as string,
build: row[buildIdx] as string,
devices: JSON.parse(row[devicesIdx] as string),
}));
}
async getPaths(build: string): Promise<string[]> {
const db = await getDB();
const results = db.exec(
`SELECT path FROM bin JOIN os ON bin.osid=os.id WHERE os.build=?`,
[build]
);
if (!results.length) return [];
return results[0].rows.map((row) => row[0] as string);
}
async getBinaryXML(build: string, path: string): Promise<string> {
const db = await getDB();
const results = db.exec(
`SELECT xml FROM bin JOIN os ON bin.osid=os.id WHERE os.build=? AND bin.path=?`,
[build, path]
);
if (!results.length || !results[0].rows.length) {
throw new Error(`Binary not found: ${path}`);
}
return results[0].rows[0][0] as string;
}
async getKeys(build: string): Promise<string[]> {
const db = await getDB();
const results = db.exec(
`SELECT DISTINCT key FROM pair JOIN bin ON pair.binid=bin.id JOIN os ON bin.osid=os.id WHERE os.build=?`,
[build]
);
if (!results.length) return [];
return results[0].rows.map((row) => row[0] as string);
}
async getPathsForKey(build: string, key: string): Promise<string[]> {
const db = await getDB();
const results = db.exec(
`SELECT path FROM bin JOIN pair ON bin.id=pair.binid JOIN os ON bin.osid=os.id WHERE os.build=? AND pair.key=?`,
[build, key]
);
if (!results.length) return [];
return results[0].rows.map((row) => row[0] as string);
}
}
+11
View File
@@ -0,0 +1,11 @@
export const basePath = process.env.NEXT_PUBLIC_BASE_PATH || "";
export function addBasePath(path: string) {
let prefixed = path;
if (!prefixed.startsWith("/")) prefixed = `/${prefixed}`;
return basePath + prefixed;
}
export function dataBaseURL(): string {
return addBasePath("/data");
}
+63
View File
@@ -0,0 +1,63 @@
interface SimpleTree {
[key: string]: SimpleTree;
}
function toTree(list: string[]): SimpleTree {
const root: SimpleTree = {};
for (const path of list) {
if (!path.startsWith("/")) {
continue;
}
const parts = path.split("/").slice(1);
let node = root;
for (const part of parts) {
if (!node[part]) {
node[part] = {};
}
node = node[part];
}
}
return root;
}
function shake(tree: SimpleTree) {
const result: SimpleTree = {};
for (const key in tree) {
const child = tree[key];
const shakenChild = shake(child);
const childKeys = Object.keys(shakenChild);
if (childKeys.length === 1) {
const grandChildKey = childKeys[0];
result[`${key}/${grandChildKey}`] = shakenChild[grandChildKey];
} else {
result[key] = shakenChild;
}
}
return result;
}
export interface TreeWithFullPath {
[key: string]: TreeWithFullPath | string;
}
function finalize(tree: SimpleTree, prefix = ""): TreeWithFullPath {
const keys = Object.keys(tree);
const result: TreeWithFullPath = {};
for (const key of keys) {
const path = prefix + "/" + key;
const child = tree[key];
result[key] =
Object.keys(child).length === 0
? path
: (finalize(tree[key], path) as TreeWithFullPath);
}
return result;
}
export default function filesToTree(list: string[]): TreeWithFullPath {
const tree1 = toTree(list);
const tree2 = shake(tree1);
const tree3 = finalize(tree2);
return tree3;
}
+11
View File
@@ -0,0 +1,11 @@
export interface OS {
name: string;
build: string;
version: string;
devices: string[];
}
export interface Group {
name: string;
list: OS[];
}
+6
View File
@@ -0,0 +1,6 @@
import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
+27
View File
@@ -0,0 +1,27 @@
{
"compilerOptions": {
"target": "ES2017",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",
"incremental": true,
"plugins": [
{
"name": "next"
}
],
"paths": {
"@/*": ["./src/*"]
}
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
"exclude": ["node_modules", "public/data/**", "data/**", "out/**"]
}