A lexicon-driven AppView for ATProto. happyview.dev
backfill firehose jetstream atproto appview oauth lexicon
8
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat: use data table with dynamic columns for records page

Trezy 2410541f 1c7d6208

+2837 -38
+3 -1
web/components.json
··· 19 19 "lib": "@/lib", 20 20 "hooks": "@/hooks" 21 21 }, 22 - "registries": {} 22 + "registries": { 23 + "@diceui": "https://diceui.com/r/{name}.json" 24 + } 23 25 }
+126
web/package-lock.json
··· 16 16 "@tanstack/react-table": "^8.21.3", 17 17 "class-variance-authority": "^0.7.1", 18 18 "clsx": "^2.1.1", 19 + "cmdk": "^1.1.1", 20 + "date-fns": "^4.1.0", 19 21 "lucide-react": "^0.564.0", 20 22 "next": "16.1.6", 21 23 "next-themes": "^0.4.6", 24 + "nuqs": "^2.8.8", 22 25 "radix-ui": "^1.4.3", 23 26 "react": "19.2.3", 27 + "react-day-picker": "^9.13.2", 24 28 "react-dom": "19.2.3", 25 29 "recharts": "^2.15.4", 26 30 "sonner": "^2.0.7", ··· 108 112 "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", 109 113 "dev": true, 110 114 "license": "MIT", 115 + "peer": true, 111 116 "dependencies": { 112 117 "@babel/code-frame": "^7.29.0", 113 118 "@babel/generator": "^7.29.0", ··· 519 524 "node": ">=6.9.0" 520 525 } 521 526 }, 527 + "node_modules/@date-fns/tz": { 528 + "version": "1.4.1", 529 + "resolved": "https://registry.npmjs.org/@date-fns/tz/-/tz-1.4.1.tgz", 530 + "integrity": "sha512-P5LUNhtbj6YfI3iJjw5EL9eUAG6OitD0W3fWQcpQjDRc/QIsL0tRNuO1PcDvPccWL1fSTXXdE1ds+l95DV/OFA==", 531 + "license": "MIT" 532 + }, 522 533 "node_modules/@dnd-kit/accessibility": { 523 534 "version": "3.1.1", 524 535 "resolved": "https://registry.npmjs.org/@dnd-kit/accessibility/-/accessibility-3.1.1.tgz", ··· 536 547 "resolved": "https://registry.npmjs.org/@dnd-kit/core/-/core-6.3.1.tgz", 537 548 "integrity": "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==", 538 549 "license": "MIT", 550 + "peer": true, 539 551 "dependencies": { 540 552 "@dnd-kit/accessibility": "^3.1.1", 541 553 "@dnd-kit/utilities": "^3.2.2", ··· 743 755 "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", 744 756 "dev": true, 745 757 "license": "MIT", 758 + "peer": true, 746 759 "engines": { 747 760 "node": ">=12" 748 761 }, ··· 1938 1951 "integrity": "sha512-2I0gnIVPtfnMw9ee9h1dJG7tp81+8Ob3OJb3Mv37rx5L40/b0i7djjCVvGOVqc9AEIQyvyu1i6ypKdFw8R8gQw==", 1939 1952 "dev": true, 1940 1953 "license": "MIT", 1954 + "peer": true, 1941 1955 "engines": { 1942 1956 "node": "^14.21.3 || >=16" 1943 1957 }, ··· 3572 3586 "url": "https://github.com/sponsors/sindresorhus" 3573 3587 } 3574 3588 }, 3589 + "node_modules/@standard-schema/spec": { 3590 + "version": "1.0.0", 3591 + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.0.0.tgz", 3592 + "integrity": "sha512-m2bOd0f2RT9k8QJx1JN85cZYyH1RqFBdlwtkSlf4tBDYLCiiZnv1fIIwacK6cqwXavOydf0NPToMQgpKq+dVlA==", 3593 + "license": "MIT" 3594 + }, 3575 3595 "node_modules/@swc/helpers": { 3576 3596 "version": "0.5.15", 3577 3597 "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz", ··· 4096 4116 "integrity": "sha512-Rs1bVAIdBs5gbTIKza/tgpMuG1k3U/UMJLWecIMxNdJFDMzcM5LOiLVRYh3PilWEYDIeUDv7bpiHPLPsbydGcw==", 4097 4117 "dev": true, 4098 4118 "license": "MIT", 4119 + "peer": true, 4099 4120 "dependencies": { 4100 4121 "undici-types": "~6.21.0" 4101 4122 } ··· 4106 4127 "integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==", 4107 4128 "devOptional": true, 4108 4129 "license": "MIT", 4130 + "peer": true, 4109 4131 "dependencies": { 4110 4132 "csstype": "^3.2.2" 4111 4133 } ··· 4116 4138 "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", 4117 4139 "devOptional": true, 4118 4140 "license": "MIT", 4141 + "peer": true, 4119 4142 "peerDependencies": { 4120 4143 "@types/react": "^19.2.0" 4121 4144 } ··· 4179 4202 "integrity": "sha512-4z2nCSBfVIMnbuu8uinj+f0o4qOeggYJLbjpPHka3KH1om7e+H9yLKTYgksTaHcGco+NClhhY2vyO3HsMH1RGw==", 4180 4203 "dev": true, 4181 4204 "license": "MIT", 4205 + "peer": true, 4182 4206 "dependencies": { 4183 4207 "@typescript-eslint/scope-manager": "8.55.0", 4184 4208 "@typescript-eslint/types": "8.55.0", ··· 4692 4716 "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", 4693 4717 "dev": true, 4694 4718 "license": "MIT", 4719 + "peer": true, 4695 4720 "bin": { 4696 4721 "acorn": "bin/acorn" 4697 4722 }, ··· 5078 5103 "integrity": "sha512-Ixm8tFfoKKIPYdCCKYTsqv+Fd4IJ0DQqMyEimo+pxUOMUR9cVPlwTrFt9Avu+3cb6Zp3mAzl+t1MrG2fxxKsxw==", 5079 5104 "devOptional": true, 5080 5105 "license": "MIT", 5106 + "peer": true, 5081 5107 "dependencies": { 5082 5108 "@babel/types": "^7.26.0" 5083 5109 } ··· 5167 5193 } 5168 5194 ], 5169 5195 "license": "MIT", 5196 + "peer": true, 5170 5197 "dependencies": { 5171 5198 "baseline-browser-mapping": "^2.9.0", 5172 5199 "caniuse-lite": "^1.0.30001759", ··· 5448 5475 "node": ">=6" 5449 5476 } 5450 5477 }, 5478 + "node_modules/cmdk": { 5479 + "version": "1.1.1", 5480 + "resolved": "https://registry.npmjs.org/cmdk/-/cmdk-1.1.1.tgz", 5481 + "integrity": "sha512-Vsv7kFaXm+ptHDMZ7izaRsP70GgrW9NBNGswt9OZaVBLlE0SNpDq8eu/VGXyF9r7M0azK3Wy7OlYXsuyYLFzHg==", 5482 + "license": "MIT", 5483 + "dependencies": { 5484 + "@radix-ui/react-compose-refs": "^1.1.1", 5485 + "@radix-ui/react-dialog": "^1.1.6", 5486 + "@radix-ui/react-id": "^1.1.0", 5487 + "@radix-ui/react-primitive": "^2.0.2" 5488 + }, 5489 + "peerDependencies": { 5490 + "react": "^18 || ^19 || ^19.0.0-rc", 5491 + "react-dom": "^18 || ^19 || ^19.0.0-rc" 5492 + } 5493 + }, 5451 5494 "node_modules/code-block-writer": { 5452 5495 "version": "13.0.3", 5453 5496 "resolved": "https://registry.npmjs.org/code-block-writer/-/code-block-writer-13.0.3.tgz", ··· 5814 5857 "url": "https://github.com/sponsors/ljharb" 5815 5858 } 5816 5859 }, 5860 + "node_modules/date-fns": { 5861 + "version": "4.1.0", 5862 + "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-4.1.0.tgz", 5863 + "integrity": "sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg==", 5864 + "license": "MIT", 5865 + "funding": { 5866 + "type": "github", 5867 + "url": "https://github.com/sponsors/kossnocorp" 5868 + } 5869 + }, 5870 + "node_modules/date-fns-jalali": { 5871 + "version": "4.1.0-0", 5872 + "resolved": "https://registry.npmjs.org/date-fns-jalali/-/date-fns-jalali-4.1.0-0.tgz", 5873 + "integrity": "sha512-hTIP/z+t+qKwBDcmmsnmjWTduxCg+5KfdqWQvb2X/8C9+knYY6epN/pfxdDuyVlSVeFz0sM5eEfwIUQ70U4ckg==", 5874 + "license": "MIT" 5875 + }, 5817 5876 "node_modules/debug": { 5818 5877 "version": "4.4.3", 5819 5878 "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", ··· 6332 6391 "integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==", 6333 6392 "dev": true, 6334 6393 "license": "MIT", 6394 + "peer": true, 6335 6395 "dependencies": { 6336 6396 "@eslint-community/eslint-utils": "^4.8.0", 6337 6397 "@eslint-community/regexpp": "^4.12.1", ··· 6517 6577 "integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==", 6518 6578 "dev": true, 6519 6579 "license": "MIT", 6580 + "peer": true, 6520 6581 "dependencies": { 6521 6582 "@rtsao/scc": "^1.1.0", 6522 6583 "array-includes": "^3.1.9", ··· 6836 6897 "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", 6837 6898 "dev": true, 6838 6899 "license": "MIT", 6900 + "peer": true, 6839 6901 "dependencies": { 6840 6902 "accepts": "^2.0.0", 6841 6903 "body-parser": "^2.2.1", ··· 7574 7636 "integrity": "sha512-Eaw2YTGM6WOxA6CXbckaEvslr2Ne4NFsKrvc0v97JD5awbmeBLO5w9Ho9L9kmKonrwF9RJlW6BxT1PVv/agBHQ==", 7575 7637 "dev": true, 7576 7638 "license": "MIT", 7639 + "peer": true, 7577 7640 "engines": { 7578 7641 "node": ">=16.9.0" 7579 7642 } ··· 9360 9423 "url": "https://github.com/sponsors/sindresorhus" 9361 9424 } 9362 9425 }, 9426 + "node_modules/nuqs": { 9427 + "version": "2.8.8", 9428 + "resolved": "https://registry.npmjs.org/nuqs/-/nuqs-2.8.8.tgz", 9429 + "integrity": "sha512-LF5sw9nWpHyPWzMMu9oho3r9C5DvkpmBIg4LQN78sexIzGaeRx8DWr0uy3YiFx5i2QGZN1Qqcb+OAtEVRa2bnA==", 9430 + "license": "MIT", 9431 + "dependencies": { 9432 + "@standard-schema/spec": "1.0.0" 9433 + }, 9434 + "funding": { 9435 + "url": "https://github.com/sponsors/franky47" 9436 + }, 9437 + "peerDependencies": { 9438 + "@remix-run/react": ">=2", 9439 + "@tanstack/react-router": "^1", 9440 + "next": ">=14.2.0", 9441 + "react": ">=18.2.0 || ^19.0.0-0", 9442 + "react-router": "^5 || ^6 || ^7", 9443 + "react-router-dom": "^5 || ^6 || ^7" 9444 + }, 9445 + "peerDependenciesMeta": { 9446 + "@remix-run/react": { 9447 + "optional": true 9448 + }, 9449 + "@tanstack/react-router": { 9450 + "optional": true 9451 + }, 9452 + "next": { 9453 + "optional": true 9454 + }, 9455 + "react-router": { 9456 + "optional": true 9457 + }, 9458 + "react-router-dom": { 9459 + "optional": true 9460 + } 9461 + } 9462 + }, 9363 9463 "node_modules/object-assign": { 9364 9464 "version": "4.1.1", 9365 9465 "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", ··· 10092 10192 "resolved": "https://registry.npmjs.org/react/-/react-19.2.3.tgz", 10093 10193 "integrity": "sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA==", 10094 10194 "license": "MIT", 10195 + "peer": true, 10095 10196 "engines": { 10096 10197 "node": ">=0.10.0" 10097 10198 } 10098 10199 }, 10200 + "node_modules/react-day-picker": { 10201 + "version": "9.13.2", 10202 + "resolved": "https://registry.npmjs.org/react-day-picker/-/react-day-picker-9.13.2.tgz", 10203 + "integrity": "sha512-IMPiXfXVIAuR5Yk58DDPBC8QKClrhdXV+Tr/alBrwrHUw0qDDYB1m5zPNuTnnPIr/gmJ4ChMxmtqPdxm8+R4Eg==", 10204 + "license": "MIT", 10205 + "dependencies": { 10206 + "@date-fns/tz": "^1.4.1", 10207 + "date-fns": "^4.1.0", 10208 + "date-fns-jalali": "^4.1.0-0" 10209 + }, 10210 + "engines": { 10211 + "node": ">=18" 10212 + }, 10213 + "funding": { 10214 + "type": "individual", 10215 + "url": "https://github.com/sponsors/gpbl" 10216 + }, 10217 + "peerDependencies": { 10218 + "react": ">=16.8.0" 10219 + } 10220 + }, 10099 10221 "node_modules/react-dom": { 10100 10222 "version": "19.2.3", 10101 10223 "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.3.tgz", 10102 10224 "integrity": "sha512-yELu4WmLPw5Mr/lmeEpox5rw3RETacE++JgHqQzd2dg+YbJuat3jH4ingc+WPZhxaoFzdv9y33G+F7Nl5O0GBg==", 10103 10225 "license": "MIT", 10226 + "peer": true, 10104 10227 "dependencies": { 10105 10228 "scheduler": "^0.27.0" 10106 10229 }, ··· 11372 11495 "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", 11373 11496 "dev": true, 11374 11497 "license": "MIT", 11498 + "peer": true, 11375 11499 "engines": { 11376 11500 "node": ">=12" 11377 11501 }, ··· 11629 11753 "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", 11630 11754 "dev": true, 11631 11755 "license": "Apache-2.0", 11756 + "peer": true, 11632 11757 "bin": { 11633 11758 "tsc": "bin/tsc", 11634 11759 "tsserver": "bin/tsserver" ··· 12264 12389 "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz", 12265 12390 "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==", 12266 12391 "license": "MIT", 12392 + "peer": true, 12267 12393 "funding": { 12268 12394 "url": "https://github.com/sponsors/colinhacks" 12269 12395 }
+4
web/package.json
··· 17 17 "@tanstack/react-table": "^8.21.3", 18 18 "class-variance-authority": "^0.7.1", 19 19 "clsx": "^2.1.1", 20 + "cmdk": "^1.1.1", 21 + "date-fns": "^4.1.0", 20 22 "lucide-react": "^0.564.0", 21 23 "next": "16.1.6", 22 24 "next-themes": "^0.4.6", 25 + "nuqs": "^2.8.8", 23 26 "radix-ui": "^1.4.3", 24 27 "react": "19.2.3", 28 + "react-day-picker": "^9.13.2", 25 29 "react-dom": "19.2.3", 26 30 "recharts": "^2.15.4", 27 31 "sonner": "^2.0.7",
+113 -36
web/src/app/(dashboard)/records/page.tsx
··· 1 1 "use client" 2 2 3 - import { useCallback, useEffect, useState } from "react" 3 + import { useCallback, useEffect, useMemo, useState } from "react" 4 + import { 5 + type ColumnDef, 6 + flexRender, 7 + getCoreRowModel, 8 + useReactTable, 9 + } from "@tanstack/react-table" 4 10 5 11 import { useAuth } from "@/lib/auth-context" 6 12 import { ··· 34 40 } from "@/components/ui/table" 35 41 36 42 function parseAtUri(uri: string): { did: string; rkey: string } { 37 - // at://did:plc:xxx/collection/rkey 38 43 const parts = uri.replace("at://", "").split("/") 39 44 return { did: parts[0] ?? "", rkey: parts[2] ?? "" } 40 45 } 41 46 42 - function truncateJson(record: AdminRecord, maxLen = 120): string { 43 - const str = JSON.stringify(record.record) 44 - return str.length > maxLen ? str.slice(0, maxLen) + "..." : str 47 + function formatCellValue(value: unknown): string { 48 + if (value === null || value === undefined) return "" 49 + if (typeof value === "string") return value 50 + if (typeof value === "number" || typeof value === "boolean") 51 + return String(value) 52 + return JSON.stringify(value) 45 53 } 46 54 47 55 export default function RecordsPage() { ··· 55 63 const [error, setError] = useState<string | null>(null) 56 64 const [viewRecord, setViewRecord] = useState<AdminRecord | null>(null) 57 65 58 - // Load collections from stats 59 66 useEffect(() => { 60 67 getStats(getToken) 61 68 .then((stats) => setCollections(stats.collections)) ··· 81 88 [getToken] 82 89 ) 83 90 91 + // Build columns dynamically from the union of all record keys 92 + const columns = useMemo<ColumnDef<AdminRecord>[]>(() => { 93 + const keySet = new Set<string>() 94 + for (const r of records) { 95 + for (const key of Object.keys(r.record)) { 96 + keySet.add(key) 97 + } 98 + } 99 + 100 + const cols: ColumnDef<AdminRecord>[] = [ 101 + { 102 + id: "did", 103 + header: "DID", 104 + accessorFn: (row) => parseAtUri(row.uri).did, 105 + cell: ({ getValue }) => ( 106 + <span className="font-mono text-xs whitespace-nowrap"> 107 + {getValue<string>()} 108 + </span> 109 + ), 110 + }, 111 + { 112 + id: "rkey", 113 + header: "Rkey", 114 + accessorFn: (row) => parseAtUri(row.uri).rkey, 115 + cell: ({ getValue }) => ( 116 + <span className="font-mono text-xs whitespace-nowrap"> 117 + {getValue<string>()} 118 + </span> 119 + ), 120 + }, 121 + ] 122 + 123 + for (const key of keySet) { 124 + cols.push({ 125 + id: key, 126 + header: key, 127 + accessorFn: (row) => row.record[key], 128 + cell: ({ getValue }) => { 129 + const val = getValue<unknown>() 130 + const str = formatCellValue(val) 131 + return ( 132 + <span 133 + className="font-mono text-xs block max-w-xs truncate" 134 + title={str} 135 + > 136 + {str} 137 + </span> 138 + ) 139 + }, 140 + }) 141 + } 142 + 143 + return cols 144 + }, [records]) 145 + 146 + const table = useReactTable({ 147 + data: records, 148 + columns, 149 + getCoreRowModel: getCoreRowModel(), 150 + getRowId: (row) => row.uri, 151 + }) 152 + 84 153 function handleSelectCollection(collection: string) { 85 154 setSelectedCollection(collection) 86 155 setCursorStack([]) ··· 97 166 function handlePrevious() { 98 167 if (cursorStack.length === 0 || !selectedCollection) return 99 168 const stack = [...cursorStack] 100 - stack.pop() // remove current page's cursor 169 + stack.pop() 101 170 const prevCursor = stack.length > 0 ? stack[stack.length - 1] : undefined 102 171 setCursorStack(stack) 103 172 fetchRecords(selectedCollection, prevCursor) ··· 110 179 {error && <p className="text-destructive text-sm">{error}</p>} 111 180 112 181 <div className="flex items-center gap-4"> 113 - <Select value={selectedCollection} onValueChange={handleSelectCollection}> 182 + <Select 183 + value={selectedCollection} 184 + onValueChange={handleSelectCollection} 185 + > 114 186 <SelectTrigger className="w-80"> 115 187 <SelectValue placeholder="Select a collection" /> 116 188 </SelectTrigger> ··· 126 198 127 199 {selectedCollection && ( 128 200 <> 129 - <div className="rounded-lg border"> 201 + <div className="overflow-x-auto rounded-lg border"> 130 202 <Table> 131 203 <TableHeader> 132 - <TableRow> 133 - <TableHead>DID</TableHead> 134 - <TableHead>Rkey</TableHead> 135 - <TableHead>Record</TableHead> 136 - </TableRow> 204 + {table.getHeaderGroups().map((headerGroup) => ( 205 + <TableRow key={headerGroup.id}> 206 + {headerGroup.headers.map((header) => ( 207 + <TableHead key={header.id} className="whitespace-nowrap"> 208 + {header.isPlaceholder 209 + ? null 210 + : flexRender( 211 + header.column.columnDef.header, 212 + header.getContext() 213 + )} 214 + </TableHead> 215 + ))} 216 + </TableRow> 217 + ))} 137 218 </TableHeader> 138 219 <TableBody> 139 220 {loading && ( 140 221 <TableRow> 141 222 <TableCell 142 - colSpan={3} 223 + colSpan={columns.length} 143 224 className="text-muted-foreground text-center" 144 225 > 145 226 Loading... 146 227 </TableCell> 147 228 </TableRow> 148 229 )} 149 - {!loading && records.length === 0 && ( 230 + {!loading && table.getRowModel().rows.length === 0 && ( 150 231 <TableRow> 151 232 <TableCell 152 - colSpan={3} 233 + colSpan={columns.length} 153 234 className="text-muted-foreground text-center" 154 235 > 155 236 No records found. ··· 157 238 </TableRow> 158 239 )} 159 240 {!loading && 160 - records.map((record) => { 161 - const { did, rkey } = parseAtUri(record.uri) 162 - return ( 163 - <TableRow 164 - key={record.uri} 165 - className="cursor-pointer" 166 - onClick={() => setViewRecord(record)} 167 - > 168 - <TableCell className="font-mono text-xs"> 169 - {did} 170 - </TableCell> 171 - <TableCell className="font-mono text-xs"> 172 - {rkey} 241 + table.getRowModel().rows.map((row) => ( 242 + <TableRow 243 + key={row.id} 244 + className="cursor-pointer" 245 + onClick={() => setViewRecord(row.original)} 246 + > 247 + {row.getVisibleCells().map((cell) => ( 248 + <TableCell key={cell.id}> 249 + {flexRender( 250 + cell.column.columnDef.cell, 251 + cell.getContext() 252 + )} 173 253 </TableCell> 174 - <TableCell className="max-w-md truncate font-mono text-xs"> 175 - {truncateJson(record)} 176 - </TableCell> 177 - </TableRow> 178 - ) 179 - })} 254 + ))} 255 + </TableRow> 256 + ))} 180 257 </TableBody> 181 258 </Table> 182 259 </div>
+99
web/src/components/data-table/data-table-column-header.tsx
··· 1 + "use client"; 2 + 3 + import type { Column } from "@tanstack/react-table"; 4 + import { 5 + ChevronDown, 6 + ChevronsUpDown, 7 + ChevronUp, 8 + EyeOff, 9 + X, 10 + } from "lucide-react"; 11 + 12 + import { 13 + DropdownMenu, 14 + DropdownMenuCheckboxItem, 15 + DropdownMenuContent, 16 + DropdownMenuItem, 17 + DropdownMenuTrigger, 18 + } from "@/components/ui/dropdown-menu"; 19 + import { cn } from "@/lib/utils"; 20 + 21 + interface DataTableColumnHeaderProps<TData, TValue> 22 + extends React.ComponentProps<typeof DropdownMenuTrigger> { 23 + column: Column<TData, TValue>; 24 + label: string; 25 + } 26 + 27 + export function DataTableColumnHeader<TData, TValue>({ 28 + column, 29 + label, 30 + className, 31 + ...props 32 + }: DataTableColumnHeaderProps<TData, TValue>) { 33 + if (!column.getCanSort() && !column.getCanHide()) { 34 + return <div className={cn(className)}>{label}</div>; 35 + } 36 + 37 + return ( 38 + <DropdownMenu> 39 + <DropdownMenuTrigger 40 + className={cn( 41 + "-ml-1.5 flex h-8 items-center gap-1.5 rounded-md px-2 py-1.5 hover:bg-accent focus:outline-none focus:ring-1 focus:ring-ring data-[state=open]:bg-accent [&_svg]:size-4 [&_svg]:shrink-0 [&_svg]:text-muted-foreground", 42 + className, 43 + )} 44 + {...props} 45 + > 46 + {label} 47 + {column.getCanSort() && 48 + (column.getIsSorted() === "desc" ? ( 49 + <ChevronDown /> 50 + ) : column.getIsSorted() === "asc" ? ( 51 + <ChevronUp /> 52 + ) : ( 53 + <ChevronsUpDown /> 54 + ))} 55 + </DropdownMenuTrigger> 56 + <DropdownMenuContent align="start" className="w-28"> 57 + {column.getCanSort() && ( 58 + <> 59 + <DropdownMenuCheckboxItem 60 + className="relative pr-8 pl-2 [&>span:first-child]:right-2 [&>span:first-child]:left-auto [&_svg]:text-muted-foreground" 61 + checked={column.getIsSorted() === "asc"} 62 + onClick={() => column.toggleSorting(false)} 63 + > 64 + <ChevronUp /> 65 + Asc 66 + </DropdownMenuCheckboxItem> 67 + <DropdownMenuCheckboxItem 68 + className="relative pr-8 pl-2 [&>span:first-child]:right-2 [&>span:first-child]:left-auto [&_svg]:text-muted-foreground" 69 + checked={column.getIsSorted() === "desc"} 70 + onClick={() => column.toggleSorting(true)} 71 + > 72 + <ChevronDown /> 73 + Desc 74 + </DropdownMenuCheckboxItem> 75 + {column.getIsSorted() && ( 76 + <DropdownMenuItem 77 + className="pl-2 [&_svg]:text-muted-foreground" 78 + onClick={() => column.clearSorting()} 79 + > 80 + <X /> 81 + Reset 82 + </DropdownMenuItem> 83 + )} 84 + </> 85 + )} 86 + {column.getCanHide() && ( 87 + <DropdownMenuCheckboxItem 88 + className="relative pr-8 pl-2 [&>span:first-child]:right-2 [&>span:first-child]:left-auto [&_svg]:text-muted-foreground" 89 + checked={!column.getIsVisible()} 90 + onClick={() => column.toggleVisibility(false)} 91 + > 92 + <EyeOff /> 93 + Hide 94 + </DropdownMenuCheckboxItem> 95 + )} 96 + </DropdownMenuContent> 97 + </DropdownMenu> 98 + ); 99 + }
+225
web/src/components/data-table/data-table-date-filter.tsx
··· 1 + "use client"; 2 + 3 + import type { Column } from "@tanstack/react-table"; 4 + import { CalendarIcon, XCircle } from "lucide-react"; 5 + import * as React from "react"; 6 + import type { DateRange } from "react-day-picker"; 7 + 8 + import { Button } from "@/components/ui/button"; 9 + import { Calendar } from "@/components/ui/calendar"; 10 + import { 11 + Popover, 12 + PopoverContent, 13 + PopoverTrigger, 14 + } from "@/components/ui/popover"; 15 + import { Separator } from "@/components/ui/separator"; 16 + import { formatDate } from "@/lib/format"; 17 + 18 + type DateSelection = Date[] | DateRange; 19 + 20 + function getIsDateRange(value: DateSelection): value is DateRange { 21 + return value && typeof value === "object" && !Array.isArray(value); 22 + } 23 + 24 + function parseAsDate(timestamp: number | string | undefined): Date | undefined { 25 + if (!timestamp) return undefined; 26 + const numericTimestamp = 27 + typeof timestamp === "string" ? Number(timestamp) : timestamp; 28 + const date = new Date(numericTimestamp); 29 + return !Number.isNaN(date.getTime()) ? date : undefined; 30 + } 31 + 32 + function parseColumnFilterValue(value: unknown) { 33 + if (value === null || value === undefined) { 34 + return []; 35 + } 36 + 37 + if (Array.isArray(value)) { 38 + return value.map((item) => { 39 + if (typeof item === "number" || typeof item === "string") { 40 + return item; 41 + } 42 + return undefined; 43 + }); 44 + } 45 + 46 + if (typeof value === "string" || typeof value === "number") { 47 + return [value]; 48 + } 49 + 50 + return []; 51 + } 52 + 53 + interface DataTableDateFilterProps<TData> { 54 + column: Column<TData, unknown>; 55 + title?: string; 56 + multiple?: boolean; 57 + } 58 + 59 + export function DataTableDateFilter<TData>({ 60 + column, 61 + title, 62 + multiple, 63 + }: DataTableDateFilterProps<TData>) { 64 + const columnFilterValue = column.getFilterValue(); 65 + 66 + const selectedDates = React.useMemo<DateSelection>(() => { 67 + if (!columnFilterValue) { 68 + return multiple ? { from: undefined, to: undefined } : []; 69 + } 70 + 71 + if (multiple) { 72 + const timestamps = parseColumnFilterValue(columnFilterValue); 73 + return { 74 + from: parseAsDate(timestamps[0]), 75 + to: parseAsDate(timestamps[1]), 76 + }; 77 + } 78 + 79 + const timestamps = parseColumnFilterValue(columnFilterValue); 80 + const date = parseAsDate(timestamps[0]); 81 + return date ? [date] : []; 82 + }, [columnFilterValue, multiple]); 83 + 84 + const onSelect = React.useCallback( 85 + (date: Date | DateRange | undefined) => { 86 + if (!date) { 87 + column.setFilterValue(undefined); 88 + return; 89 + } 90 + 91 + if (multiple && !("getTime" in date)) { 92 + const from = date.from?.getTime(); 93 + const to = date.to?.getTime(); 94 + column.setFilterValue(from || to ? [from, to] : undefined); 95 + } else if (!multiple && "getTime" in date) { 96 + column.setFilterValue(date.getTime()); 97 + } 98 + }, 99 + [column, multiple], 100 + ); 101 + 102 + const onReset = React.useCallback( 103 + (event: React.MouseEvent) => { 104 + event.stopPropagation(); 105 + column.setFilterValue(undefined); 106 + }, 107 + [column], 108 + ); 109 + 110 + const hasValue = React.useMemo(() => { 111 + if (multiple) { 112 + if (!getIsDateRange(selectedDates)) return false; 113 + return selectedDates.from || selectedDates.to; 114 + } 115 + if (!Array.isArray(selectedDates)) return false; 116 + return selectedDates.length > 0; 117 + }, [multiple, selectedDates]); 118 + 119 + const formatDateRange = React.useCallback((range: DateRange) => { 120 + if (!range.from && !range.to) return ""; 121 + if (range.from && range.to) { 122 + return `${formatDate(range.from)} - ${formatDate(range.to)}`; 123 + } 124 + return formatDate(range.from ?? range.to); 125 + }, []); 126 + 127 + const label = React.useMemo(() => { 128 + if (multiple) { 129 + if (!getIsDateRange(selectedDates)) return null; 130 + 131 + const hasSelectedDates = selectedDates.from || selectedDates.to; 132 + const dateText = hasSelectedDates 133 + ? formatDateRange(selectedDates) 134 + : "Select date range"; 135 + 136 + return ( 137 + <span className="flex items-center gap-2"> 138 + <span>{title}</span> 139 + {hasSelectedDates && ( 140 + <> 141 + <Separator 142 + orientation="vertical" 143 + className="mx-0.5 data-[orientation=vertical]:h-4" 144 + /> 145 + <span>{dateText}</span> 146 + </> 147 + )} 148 + </span> 149 + ); 150 + } 151 + 152 + if (getIsDateRange(selectedDates)) return null; 153 + 154 + const hasSelectedDate = selectedDates.length > 0; 155 + const dateText = hasSelectedDate 156 + ? formatDate(selectedDates[0]) 157 + : "Select date"; 158 + 159 + return ( 160 + <span className="flex items-center gap-2"> 161 + <span>{title}</span> 162 + {hasSelectedDate && ( 163 + <> 164 + <Separator 165 + orientation="vertical" 166 + className="mx-0.5 data-[orientation=vertical]:h-4" 167 + /> 168 + <span>{dateText}</span> 169 + </> 170 + )} 171 + </span> 172 + ); 173 + }, [selectedDates, multiple, formatDateRange, title]); 174 + 175 + return ( 176 + <Popover> 177 + <PopoverTrigger asChild> 178 + <Button 179 + variant="outline" 180 + size="sm" 181 + className="border-dashed font-normal" 182 + > 183 + {hasValue ? ( 184 + <div 185 + role="button" 186 + aria-label={`Clear ${title} filter`} 187 + tabIndex={0} 188 + onClick={onReset} 189 + className="rounded-sm opacity-70 transition-opacity hover:opacity-100 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring" 190 + > 191 + <XCircle /> 192 + </div> 193 + ) : ( 194 + <CalendarIcon /> 195 + )} 196 + {label} 197 + </Button> 198 + </PopoverTrigger> 199 + <PopoverContent className="w-auto p-0" align="start"> 200 + {multiple ? ( 201 + <Calendar 202 + autoFocus 203 + captionLayout="dropdown" 204 + mode="range" 205 + selected={ 206 + getIsDateRange(selectedDates) 207 + ? selectedDates 208 + : { from: undefined, to: undefined } 209 + } 210 + onSelect={onSelect} 211 + /> 212 + ) : ( 213 + <Calendar 214 + captionLayout="dropdown" 215 + mode="single" 216 + selected={ 217 + !getIsDateRange(selectedDates) ? selectedDates[0] : undefined 218 + } 219 + onSelect={onSelect} 220 + /> 221 + )} 222 + </PopoverContent> 223 + </Popover> 224 + ); 225 + }
+189
web/src/components/data-table/data-table-faceted-filter.tsx
··· 1 + "use client"; 2 + 3 + import type { Column } from "@tanstack/react-table"; 4 + import { Check, PlusCircle, XCircle } from "lucide-react"; 5 + import * as React from "react"; 6 + 7 + import { Badge } from "@/components/ui/badge"; 8 + import { Button } from "@/components/ui/button"; 9 + import { 10 + Command, 11 + CommandEmpty, 12 + CommandGroup, 13 + CommandInput, 14 + CommandItem, 15 + CommandList, 16 + CommandSeparator, 17 + } from "@/components/ui/command"; 18 + import { 19 + Popover, 20 + PopoverContent, 21 + PopoverTrigger, 22 + } from "@/components/ui/popover"; 23 + import { Separator } from "@/components/ui/separator"; 24 + import { cn } from "@/lib/utils"; 25 + import type { Option } from "@/types/data-table"; 26 + 27 + interface DataTableFacetedFilterProps<TData, TValue> { 28 + column?: Column<TData, TValue>; 29 + title?: string; 30 + options: Option[]; 31 + multiple?: boolean; 32 + } 33 + 34 + export function DataTableFacetedFilter<TData, TValue>({ 35 + column, 36 + title, 37 + options, 38 + multiple, 39 + }: DataTableFacetedFilterProps<TData, TValue>) { 40 + const [open, setOpen] = React.useState(false); 41 + 42 + const columnFilterValue = column?.getFilterValue(); 43 + const selectedValues = new Set( 44 + Array.isArray(columnFilterValue) ? columnFilterValue : [], 45 + ); 46 + 47 + const onItemSelect = React.useCallback( 48 + (option: Option, isSelected: boolean) => { 49 + if (!column) return; 50 + 51 + if (multiple) { 52 + const newSelectedValues = new Set(selectedValues); 53 + if (isSelected) { 54 + newSelectedValues.delete(option.value); 55 + } else { 56 + newSelectedValues.add(option.value); 57 + } 58 + const filterValues = Array.from(newSelectedValues); 59 + column.setFilterValue(filterValues.length ? filterValues : undefined); 60 + } else { 61 + column.setFilterValue(isSelected ? undefined : [option.value]); 62 + setOpen(false); 63 + } 64 + }, 65 + [column, multiple, selectedValues], 66 + ); 67 + 68 + const onReset = React.useCallback( 69 + (event?: React.MouseEvent) => { 70 + event?.stopPropagation(); 71 + column?.setFilterValue(undefined); 72 + }, 73 + [column], 74 + ); 75 + 76 + return ( 77 + <Popover open={open} onOpenChange={setOpen}> 78 + <PopoverTrigger asChild> 79 + <Button 80 + variant="outline" 81 + size="sm" 82 + className="border-dashed font-normal" 83 + > 84 + {selectedValues?.size > 0 ? ( 85 + <div 86 + role="button" 87 + aria-label={`Clear ${title} filter`} 88 + tabIndex={0} 89 + className="rounded-sm opacity-70 transition-opacity hover:opacity-100 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring" 90 + onClick={onReset} 91 + > 92 + <XCircle /> 93 + </div> 94 + ) : ( 95 + <PlusCircle /> 96 + )} 97 + {title} 98 + {selectedValues?.size > 0 && ( 99 + <> 100 + <Separator 101 + orientation="vertical" 102 + className="mx-0.5 data-[orientation=vertical]:h-4" 103 + /> 104 + <Badge 105 + variant="secondary" 106 + className="rounded-sm px-1 font-normal lg:hidden" 107 + > 108 + {selectedValues.size} 109 + </Badge> 110 + <div className="hidden items-center gap-1 lg:flex"> 111 + {selectedValues.size > 2 ? ( 112 + <Badge 113 + variant="secondary" 114 + className="rounded-sm px-1 font-normal" 115 + > 116 + {selectedValues.size} selected 117 + </Badge> 118 + ) : ( 119 + options 120 + .filter((option) => selectedValues.has(option.value)) 121 + .map((option) => ( 122 + <Badge 123 + variant="secondary" 124 + key={option.value} 125 + className="rounded-sm px-1 font-normal" 126 + > 127 + {option.label} 128 + </Badge> 129 + )) 130 + )} 131 + </div> 132 + </> 133 + )} 134 + </Button> 135 + </PopoverTrigger> 136 + <PopoverContent className="w-50 p-0" align="start"> 137 + <Command> 138 + <CommandInput placeholder={title} /> 139 + <CommandList className="max-h-full"> 140 + <CommandEmpty>No results found.</CommandEmpty> 141 + <CommandGroup className="max-h-[300px] scroll-py-1 overflow-y-auto overflow-x-hidden"> 142 + {options.map((option) => { 143 + const isSelected = selectedValues.has(option.value); 144 + 145 + return ( 146 + <CommandItem 147 + key={option.value} 148 + onSelect={() => onItemSelect(option, isSelected)} 149 + > 150 + <div 151 + className={cn( 152 + "flex size-4 items-center justify-center rounded-sm border border-primary", 153 + isSelected 154 + ? "bg-primary" 155 + : "opacity-50 [&_svg]:invisible", 156 + )} 157 + > 158 + <Check /> 159 + </div> 160 + {option.icon && <option.icon />} 161 + <span className="truncate">{option.label}</span> 162 + {option.count && ( 163 + <span className="ml-auto font-mono text-xs"> 164 + {option.count} 165 + </span> 166 + )} 167 + </CommandItem> 168 + ); 169 + })} 170 + </CommandGroup> 171 + {selectedValues.size > 0 && ( 172 + <> 173 + <CommandSeparator /> 174 + <CommandGroup> 175 + <CommandItem 176 + onSelect={() => onReset()} 177 + className="justify-center text-center" 178 + > 179 + Clear filters 180 + </CommandItem> 181 + </CommandGroup> 182 + </> 183 + )} 184 + </CommandList> 185 + </Command> 186 + </PopoverContent> 187 + </Popover> 188 + ); 189 + }
+112
web/src/components/data-table/data-table-pagination.tsx
··· 1 + import type { Table } from "@tanstack/react-table"; 2 + import { 3 + ChevronLeft, 4 + ChevronRight, 5 + ChevronsLeft, 6 + ChevronsRight, 7 + } from "lucide-react"; 8 + 9 + import { Button } from "@/components/ui/button"; 10 + import { 11 + Select, 12 + SelectContent, 13 + SelectItem, 14 + SelectTrigger, 15 + SelectValue, 16 + } from "@/components/ui/select"; 17 + import { cn } from "@/lib/utils"; 18 + 19 + interface DataTablePaginationProps<TData> extends React.ComponentProps<"div"> { 20 + table: Table<TData>; 21 + pageSizeOptions?: number[]; 22 + } 23 + 24 + export function DataTablePagination<TData>({ 25 + table, 26 + pageSizeOptions = [10, 20, 30, 40, 50], 27 + className, 28 + ...props 29 + }: DataTablePaginationProps<TData>) { 30 + return ( 31 + <div 32 + className={cn( 33 + "flex w-full flex-col-reverse items-center justify-between gap-4 overflow-auto p-1 sm:flex-row sm:gap-8", 34 + className, 35 + )} 36 + {...props} 37 + > 38 + <div className="flex-1 whitespace-nowrap text-muted-foreground text-sm"> 39 + {table.getFilteredSelectedRowModel().rows.length} of{" "} 40 + {table.getFilteredRowModel().rows.length} row(s) selected. 41 + </div> 42 + <div className="flex flex-col-reverse items-center gap-4 sm:flex-row sm:gap-6 lg:gap-8"> 43 + <div className="flex items-center space-x-2"> 44 + <p className="whitespace-nowrap font-medium text-sm">Rows per page</p> 45 + <Select 46 + value={`${table.getState().pagination.pageSize}`} 47 + onValueChange={(value) => { 48 + table.setPageSize(Number(value)); 49 + }} 50 + > 51 + <SelectTrigger className="h-8 w-18 data-size:h-8"> 52 + <SelectValue placeholder={table.getState().pagination.pageSize} /> 53 + </SelectTrigger> 54 + <SelectContent side="top"> 55 + {pageSizeOptions.map((pageSize) => ( 56 + <SelectItem key={pageSize} value={`${pageSize}`}> 57 + {pageSize} 58 + </SelectItem> 59 + ))} 60 + </SelectContent> 61 + </Select> 62 + </div> 63 + <div className="flex items-center justify-center font-medium text-sm"> 64 + Page {table.getState().pagination.pageIndex + 1} of{" "} 65 + {table.getPageCount()} 66 + </div> 67 + <div className="flex items-center space-x-2"> 68 + <Button 69 + aria-label="Go to first page" 70 + variant="outline" 71 + size="icon" 72 + className="hidden size-8 lg:flex" 73 + onClick={() => table.setPageIndex(0)} 74 + disabled={!table.getCanPreviousPage()} 75 + > 76 + <ChevronsLeft /> 77 + </Button> 78 + <Button 79 + aria-label="Go to previous page" 80 + variant="outline" 81 + size="icon" 82 + className="size-8" 83 + onClick={() => table.previousPage()} 84 + disabled={!table.getCanPreviousPage()} 85 + > 86 + <ChevronLeft /> 87 + </Button> 88 + <Button 89 + aria-label="Go to next page" 90 + variant="outline" 91 + size="icon" 92 + className="size-8" 93 + onClick={() => table.nextPage()} 94 + disabled={!table.getCanNextPage()} 95 + > 96 + <ChevronRight /> 97 + </Button> 98 + <Button 99 + aria-label="Go to last page" 100 + variant="outline" 101 + size="icon" 102 + className="hidden size-8 lg:flex" 103 + onClick={() => table.setPageIndex(table.getPageCount() - 1)} 104 + disabled={!table.getCanNextPage()} 105 + > 106 + <ChevronsRight /> 107 + </Button> 108 + </div> 109 + </div> 110 + </div> 111 + ); 112 + }
+115
web/src/components/data-table/data-table-skeleton.tsx
··· 1 + import { Skeleton } from "@/components/ui/skeleton"; 2 + import { 3 + Table, 4 + TableBody, 5 + TableCell, 6 + TableHead, 7 + TableHeader, 8 + TableRow, 9 + } from "@/components/ui/table"; 10 + import { cn } from "@/lib/utils"; 11 + 12 + interface DataTableSkeletonProps extends React.ComponentProps<"div"> { 13 + columnCount: number; 14 + rowCount?: number; 15 + filterCount?: number; 16 + cellWidths?: string[]; 17 + withViewOptions?: boolean; 18 + withPagination?: boolean; 19 + shrinkZero?: boolean; 20 + } 21 + 22 + export function DataTableSkeleton({ 23 + columnCount, 24 + rowCount = 10, 25 + filterCount = 0, 26 + cellWidths = ["auto"], 27 + withViewOptions = true, 28 + withPagination = true, 29 + shrinkZero = false, 30 + className, 31 + ...props 32 + }: DataTableSkeletonProps) { 33 + const cozyCellWidths = Array.from( 34 + { length: columnCount }, 35 + (_, index) => cellWidths[index % cellWidths.length] ?? "auto", 36 + ); 37 + 38 + return ( 39 + <div 40 + className={cn("flex w-full flex-col gap-2.5 overflow-auto", className)} 41 + {...props} 42 + > 43 + <div className="flex w-full items-center justify-between gap-2 overflow-auto p-1"> 44 + <div className="flex flex-1 items-center gap-2"> 45 + {filterCount > 0 46 + ? Array.from({ length: filterCount }).map((_, i) => ( 47 + <Skeleton key={i} className="h-7 w-18 border-dashed" /> 48 + )) 49 + : null} 50 + </div> 51 + {withViewOptions ? ( 52 + <Skeleton className="ml-auto hidden h-7 w-18 lg:flex" /> 53 + ) : null} 54 + </div> 55 + <div className="rounded-md border"> 56 + <Table> 57 + <TableHeader> 58 + {Array.from({ length: 1 }).map((_, i) => ( 59 + <TableRow key={i} className="hover:bg-transparent"> 60 + {Array.from({ length: columnCount }).map((_, j) => ( 61 + <TableHead 62 + key={j} 63 + style={{ 64 + width: cozyCellWidths[j], 65 + minWidth: shrinkZero ? cozyCellWidths[j] : "auto", 66 + }} 67 + > 68 + <Skeleton className="h-6 w-full" /> 69 + </TableHead> 70 + ))} 71 + </TableRow> 72 + ))} 73 + </TableHeader> 74 + <TableBody> 75 + {Array.from({ length: rowCount }).map((_, i) => ( 76 + <TableRow key={i} className="hover:bg-transparent"> 77 + {Array.from({ length: columnCount }).map((_, j) => ( 78 + <TableCell 79 + key={j} 80 + style={{ 81 + width: cozyCellWidths[j], 82 + minWidth: shrinkZero ? cozyCellWidths[j] : "auto", 83 + }} 84 + > 85 + <Skeleton className="h-6 w-full" /> 86 + </TableCell> 87 + ))} 88 + </TableRow> 89 + ))} 90 + </TableBody> 91 + </Table> 92 + </div> 93 + {withPagination ? ( 94 + <div className="flex w-full items-center justify-between gap-4 overflow-auto p-1 sm:gap-8"> 95 + <Skeleton className="h-7 w-40 shrink-0" /> 96 + <div className="flex items-center gap-4 sm:gap-6 lg:gap-8"> 97 + <div className="flex items-center gap-2"> 98 + <Skeleton className="h-7 w-24" /> 99 + <Skeleton className="h-7 w-18" /> 100 + </div> 101 + <div className="flex items-center justify-center font-medium text-sm"> 102 + <Skeleton className="h-7 w-20" /> 103 + </div> 104 + <div className="flex items-center gap-2"> 105 + <Skeleton className="hidden size-7 lg:block" /> 106 + <Skeleton className="size-7" /> 107 + <Skeleton className="size-7" /> 108 + <Skeleton className="hidden size-7 lg:block" /> 109 + </div> 110 + </div> 111 + </div> 112 + ) : null} 113 + </div> 114 + ); 115 + }
+256
web/src/components/data-table/data-table-slider-filter.tsx
··· 1 + "use client"; 2 + 3 + import type { Column } from "@tanstack/react-table"; 4 + import { PlusCircle, XCircle } from "lucide-react"; 5 + import * as React from "react"; 6 + import { Button } from "@/components/ui/button"; 7 + import { Input } from "@/components/ui/input"; 8 + import { Label } from "@/components/ui/label"; 9 + import { 10 + Popover, 11 + PopoverContent, 12 + PopoverTrigger, 13 + } from "@/components/ui/popover"; 14 + import { Separator } from "@/components/ui/separator"; 15 + import { Slider } from "@/components/ui/slider"; 16 + import { cn } from "@/lib/utils"; 17 + 18 + interface Range { 19 + min: number; 20 + max: number; 21 + } 22 + 23 + type RangeValue = [number, number]; 24 + 25 + function getIsValidRange(value: unknown): value is RangeValue { 26 + return ( 27 + Array.isArray(value) && 28 + value.length === 2 && 29 + typeof value[0] === "number" && 30 + typeof value[1] === "number" 31 + ); 32 + } 33 + 34 + function parseValuesAsNumbers(value: unknown): RangeValue | undefined { 35 + if ( 36 + Array.isArray(value) && 37 + value.length === 2 && 38 + value.every( 39 + (v) => 40 + (typeof v === "string" || typeof v === "number") && !Number.isNaN(v), 41 + ) 42 + ) { 43 + return [Number(value[0]), Number(value[1])]; 44 + } 45 + 46 + return undefined; 47 + } 48 + 49 + interface DataTableSliderFilterProps<TData> { 50 + column: Column<TData, unknown>; 51 + title?: string; 52 + } 53 + 54 + export function DataTableSliderFilter<TData>({ 55 + column, 56 + title, 57 + }: DataTableSliderFilterProps<TData>) { 58 + const id = React.useId(); 59 + 60 + const columnFilterValue = parseValuesAsNumbers(column.getFilterValue()); 61 + 62 + const defaultRange = column.columnDef.meta?.range; 63 + const unit = column.columnDef.meta?.unit; 64 + 65 + const { min, max, step } = React.useMemo<Range & { step: number }>(() => { 66 + let minValue = 0; 67 + let maxValue = 100; 68 + 69 + if (defaultRange && getIsValidRange(defaultRange)) { 70 + [minValue, maxValue] = defaultRange; 71 + } else { 72 + const values = column.getFacetedMinMaxValues(); 73 + if (values && Array.isArray(values) && values.length === 2) { 74 + const [facetMinValue, facetMaxValue] = values; 75 + if ( 76 + typeof facetMinValue === "number" && 77 + typeof facetMaxValue === "number" 78 + ) { 79 + minValue = facetMinValue; 80 + maxValue = facetMaxValue; 81 + } 82 + } 83 + } 84 + 85 + const rangeSize = maxValue - minValue; 86 + const step = 87 + rangeSize <= 20 88 + ? 1 89 + : rangeSize <= 100 90 + ? Math.ceil(rangeSize / 20) 91 + : Math.ceil(rangeSize / 50); 92 + 93 + return { min: minValue, max: maxValue, step }; 94 + }, [column, defaultRange]); 95 + 96 + const range = React.useMemo((): RangeValue => { 97 + return columnFilterValue ?? [min, max]; 98 + }, [columnFilterValue, min, max]); 99 + 100 + const formatValue = React.useCallback((value: number) => { 101 + return value.toLocaleString(undefined, { maximumFractionDigits: 0 }); 102 + }, []); 103 + 104 + const onFromInputChange = React.useCallback( 105 + (event: React.ChangeEvent<HTMLInputElement>) => { 106 + const numValue = Number(event.target.value); 107 + if (!Number.isNaN(numValue) && numValue >= min && numValue <= range[1]) { 108 + column.setFilterValue([numValue, range[1]]); 109 + } 110 + }, 111 + [column, min, range], 112 + ); 113 + 114 + const onToInputChange = React.useCallback( 115 + (event: React.ChangeEvent<HTMLInputElement>) => { 116 + const numValue = Number(event.target.value); 117 + if (!Number.isNaN(numValue) && numValue <= max && numValue >= range[0]) { 118 + column.setFilterValue([range[0], numValue]); 119 + } 120 + }, 121 + [column, max, range], 122 + ); 123 + 124 + const onSliderValueChange = React.useCallback( 125 + (value: RangeValue) => { 126 + if (Array.isArray(value) && value.length === 2) { 127 + column.setFilterValue(value); 128 + } 129 + }, 130 + [column], 131 + ); 132 + 133 + const onReset = React.useCallback( 134 + (event: React.MouseEvent) => { 135 + if (event.target instanceof HTMLDivElement) { 136 + event.stopPropagation(); 137 + } 138 + column.setFilterValue(undefined); 139 + }, 140 + [column], 141 + ); 142 + 143 + return ( 144 + <Popover> 145 + <PopoverTrigger asChild> 146 + <Button 147 + variant="outline" 148 + size="sm" 149 + className="border-dashed font-normal" 150 + > 151 + {columnFilterValue ? ( 152 + <div 153 + role="button" 154 + aria-label={`Clear ${title} filter`} 155 + tabIndex={0} 156 + className="rounded-sm opacity-70 transition-opacity hover:opacity-100 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring" 157 + onClick={onReset} 158 + > 159 + <XCircle /> 160 + </div> 161 + ) : ( 162 + <PlusCircle /> 163 + )} 164 + <span>{title}</span> 165 + {columnFilterValue ? ( 166 + <> 167 + <Separator 168 + orientation="vertical" 169 + className="mx-0.5 data-[orientation=vertical]:h-4" 170 + /> 171 + {formatValue(columnFilterValue[0])} -{" "} 172 + {formatValue(columnFilterValue[1])} 173 + {unit ? ` ${unit}` : ""} 174 + </> 175 + ) : null} 176 + </Button> 177 + </PopoverTrigger> 178 + <PopoverContent align="start" className="flex w-auto flex-col gap-4"> 179 + <div className="flex flex-col gap-3"> 180 + <p className="font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"> 181 + {title} 182 + </p> 183 + <div className="flex items-center gap-4"> 184 + <Label htmlFor={`${id}-from`} className="sr-only"> 185 + From 186 + </Label> 187 + <div className="relative"> 188 + <Input 189 + id={`${id}-from`} 190 + type="number" 191 + aria-valuemin={min} 192 + aria-valuemax={max} 193 + inputMode="numeric" 194 + pattern="[0-9]*" 195 + placeholder={min.toString()} 196 + min={min} 197 + max={max} 198 + value={range[0]?.toString()} 199 + onChange={onFromInputChange} 200 + className={cn("h-8 w-24", unit && "pr-8")} 201 + /> 202 + {unit && ( 203 + <span className="absolute top-0 right-0 bottom-0 flex items-center rounded-r-md bg-accent px-2 text-muted-foreground text-sm"> 204 + {unit} 205 + </span> 206 + )} 207 + </div> 208 + <Label htmlFor={`${id}-to`} className="sr-only"> 209 + to 210 + </Label> 211 + <div className="relative"> 212 + <Input 213 + id={`${id}-to`} 214 + type="number" 215 + aria-valuemin={min} 216 + aria-valuemax={max} 217 + inputMode="numeric" 218 + pattern="[0-9]*" 219 + placeholder={max.toString()} 220 + min={min} 221 + max={max} 222 + value={range[1]?.toString()} 223 + onChange={onToInputChange} 224 + className={cn("h-8 w-24", unit && "pr-8")} 225 + /> 226 + {unit && ( 227 + <span className="absolute top-0 right-0 bottom-0 flex items-center rounded-r-md bg-accent px-2 text-muted-foreground text-sm"> 228 + {unit} 229 + </span> 230 + )} 231 + </div> 232 + </div> 233 + <Label htmlFor={`${id}-slider`} className="sr-only"> 234 + {title} slider 235 + </Label> 236 + <Slider 237 + id={`${id}-slider`} 238 + min={min} 239 + max={max} 240 + step={step} 241 + value={range} 242 + onValueChange={onSliderValueChange} 243 + /> 244 + </div> 245 + <Button 246 + aria-label={`Clear ${title} filter`} 247 + variant="outline" 248 + size="sm" 249 + onClick={onReset} 250 + > 251 + Clear 252 + </Button> 253 + </PopoverContent> 254 + </Popover> 255 + ); 256 + }
+149
web/src/components/data-table/data-table-toolbar.tsx
··· 1 + "use client"; 2 + 3 + import type { Column, Table } from "@tanstack/react-table"; 4 + import { X } from "lucide-react"; 5 + import * as React from "react"; 6 + 7 + import { DataTableDateFilter } from "@/components/data-table/data-table-date-filter"; 8 + import { DataTableFacetedFilter } from "@/components/data-table/data-table-faceted-filter"; 9 + import { DataTableSliderFilter } from "@/components/data-table/data-table-slider-filter"; 10 + import { DataTableViewOptions } from "@/components/data-table/data-table-view-options"; 11 + import { Button } from "@/components/ui/button"; 12 + import { Input } from "@/components/ui/input"; 13 + import { cn } from "@/lib/utils"; 14 + 15 + interface DataTableToolbarProps<TData> extends React.ComponentProps<"div"> { 16 + table: Table<TData>; 17 + } 18 + 19 + export function DataTableToolbar<TData>({ 20 + table, 21 + children, 22 + className, 23 + ...props 24 + }: DataTableToolbarProps<TData>) { 25 + const isFiltered = table.getState().columnFilters.length > 0; 26 + 27 + const columns = React.useMemo( 28 + () => table.getAllColumns().filter((column) => column.getCanFilter()), 29 + [table], 30 + ); 31 + 32 + const onReset = React.useCallback(() => { 33 + table.resetColumnFilters(); 34 + }, [table]); 35 + 36 + return ( 37 + <div 38 + role="toolbar" 39 + aria-orientation="horizontal" 40 + className={cn( 41 + "flex w-full items-start justify-between gap-2 p-1", 42 + className, 43 + )} 44 + {...props} 45 + > 46 + <div className="flex flex-1 flex-wrap items-center gap-2"> 47 + {columns.map((column) => ( 48 + <DataTableToolbarFilter key={column.id} column={column} /> 49 + ))} 50 + {isFiltered && ( 51 + <Button 52 + aria-label="Reset filters" 53 + variant="outline" 54 + size="sm" 55 + className="border-dashed" 56 + onClick={onReset} 57 + > 58 + <X /> 59 + Reset 60 + </Button> 61 + )} 62 + </div> 63 + <div className="flex items-center gap-2"> 64 + {children} 65 + <DataTableViewOptions table={table} align="end" /> 66 + </div> 67 + </div> 68 + ); 69 + } 70 + interface DataTableToolbarFilterProps<TData> { 71 + column: Column<TData>; 72 + } 73 + 74 + function DataTableToolbarFilter<TData>({ 75 + column, 76 + }: DataTableToolbarFilterProps<TData>) { 77 + { 78 + const columnMeta = column.columnDef.meta; 79 + 80 + const onFilterRender = React.useCallback(() => { 81 + if (!columnMeta?.variant) return null; 82 + 83 + switch (columnMeta.variant) { 84 + case "text": 85 + return ( 86 + <Input 87 + placeholder={columnMeta.placeholder ?? columnMeta.label} 88 + value={(column.getFilterValue() as string) ?? ""} 89 + onChange={(event) => column.setFilterValue(event.target.value)} 90 + className="h-8 w-40 lg:w-56" 91 + /> 92 + ); 93 + 94 + case "number": 95 + return ( 96 + <div className="relative"> 97 + <Input 98 + type="number" 99 + inputMode="numeric" 100 + placeholder={columnMeta.placeholder ?? columnMeta.label} 101 + value={(column.getFilterValue() as string) ?? ""} 102 + onChange={(event) => column.setFilterValue(event.target.value)} 103 + className={cn("h-8 w-[120px]", columnMeta.unit && "pr-8")} 104 + /> 105 + {columnMeta.unit && ( 106 + <span className="absolute top-0 right-0 bottom-0 flex items-center rounded-r-md bg-accent px-2 text-muted-foreground text-sm"> 107 + {columnMeta.unit} 108 + </span> 109 + )} 110 + </div> 111 + ); 112 + 113 + case "range": 114 + return ( 115 + <DataTableSliderFilter 116 + column={column} 117 + title={columnMeta.label ?? column.id} 118 + /> 119 + ); 120 + 121 + case "date": 122 + case "dateRange": 123 + return ( 124 + <DataTableDateFilter 125 + column={column} 126 + title={columnMeta.label ?? column.id} 127 + multiple={columnMeta.variant === "dateRange"} 128 + /> 129 + ); 130 + 131 + case "select": 132 + case "multiSelect": 133 + return ( 134 + <DataTableFacetedFilter 135 + column={column} 136 + title={columnMeta.label ?? column.id} 137 + options={columnMeta.options ?? []} 138 + multiple={columnMeta.variant === "multiSelect"} 139 + /> 140 + ); 141 + 142 + default: 143 + return null; 144 + } 145 + }, [column, columnMeta]); 146 + 147 + return onFilterRender(); 148 + } 149 + }
+89
web/src/components/data-table/data-table-view-options.tsx
··· 1 + "use client"; 2 + 3 + import type { Table } from "@tanstack/react-table"; 4 + import { Check, Settings2 } from "lucide-react"; 5 + import * as React from "react"; 6 + import { Button } from "@/components/ui/button"; 7 + import { 8 + Command, 9 + CommandEmpty, 10 + CommandGroup, 11 + CommandInput, 12 + CommandItem, 13 + CommandList, 14 + } from "@/components/ui/command"; 15 + import { 16 + Popover, 17 + PopoverContent, 18 + PopoverTrigger, 19 + } from "@/components/ui/popover"; 20 + import { cn } from "@/lib/utils"; 21 + 22 + interface DataTableViewOptionsProps<TData> 23 + extends React.ComponentProps<typeof PopoverContent> { 24 + table: Table<TData>; 25 + disabled?: boolean; 26 + } 27 + 28 + export function DataTableViewOptions<TData>({ 29 + table, 30 + disabled, 31 + ...props 32 + }: DataTableViewOptionsProps<TData>) { 33 + const columns = React.useMemo( 34 + () => 35 + table 36 + .getAllColumns() 37 + .filter( 38 + (column) => 39 + typeof column.accessorFn !== "undefined" && column.getCanHide(), 40 + ), 41 + [table], 42 + ); 43 + 44 + return ( 45 + <Popover> 46 + <PopoverTrigger asChild> 47 + <Button 48 + aria-label="Toggle columns" 49 + role="combobox" 50 + variant="outline" 51 + size="sm" 52 + className="ml-auto hidden h-8 font-normal lg:flex" 53 + disabled={disabled} 54 + > 55 + <Settings2 className="text-muted-foreground" /> 56 + View 57 + </Button> 58 + </PopoverTrigger> 59 + <PopoverContent className="w-44 p-0" {...props}> 60 + <Command> 61 + <CommandInput placeholder="Search columns..." /> 62 + <CommandList> 63 + <CommandEmpty>No columns found.</CommandEmpty> 64 + <CommandGroup> 65 + {columns.map((column) => ( 66 + <CommandItem 67 + key={column.id} 68 + onSelect={() => 69 + column.toggleVisibility(!column.getIsVisible()) 70 + } 71 + > 72 + <span className="truncate"> 73 + {column.columnDef.meta?.label ?? column.id} 74 + </span> 75 + <Check 76 + className={cn( 77 + "ml-auto size-4 shrink-0", 78 + column.getIsVisible() ? "opacity-100" : "opacity-0", 79 + )} 80 + /> 81 + </CommandItem> 82 + ))} 83 + </CommandGroup> 84 + </CommandList> 85 + </Command> 86 + </PopoverContent> 87 + </Popover> 88 + ); 89 + }
+101
web/src/components/data-table/data-table.tsx
··· 1 + import { flexRender, type Table as TanstackTable } from "@tanstack/react-table"; 2 + import type * as React from "react"; 3 + 4 + import { DataTablePagination } from "@/components/data-table/data-table-pagination"; 5 + import { 6 + Table, 7 + TableBody, 8 + TableCell, 9 + TableHead, 10 + TableHeader, 11 + TableRow, 12 + } from "@/components/ui/table"; 13 + import { getColumnPinningStyle } from "@/lib/data-table"; 14 + import { cn } from "@/lib/utils"; 15 + 16 + interface DataTableProps<TData> extends React.ComponentProps<"div"> { 17 + table: TanstackTable<TData>; 18 + actionBar?: React.ReactNode; 19 + } 20 + 21 + export function DataTable<TData>({ 22 + table, 23 + actionBar, 24 + children, 25 + className, 26 + ...props 27 + }: DataTableProps<TData>) { 28 + return ( 29 + <div 30 + className={cn("flex w-full flex-col gap-2.5 overflow-auto", className)} 31 + {...props} 32 + > 33 + {children} 34 + <div className="overflow-hidden rounded-md border"> 35 + <Table> 36 + <TableHeader> 37 + {table.getHeaderGroups().map((headerGroup) => ( 38 + <TableRow key={headerGroup.id}> 39 + {headerGroup.headers.map((header) => ( 40 + <TableHead 41 + key={header.id} 42 + colSpan={header.colSpan} 43 + style={{ 44 + ...getColumnPinningStyle({ column: header.column }), 45 + }} 46 + > 47 + {header.isPlaceholder 48 + ? null 49 + : flexRender( 50 + header.column.columnDef.header, 51 + header.getContext(), 52 + )} 53 + </TableHead> 54 + ))} 55 + </TableRow> 56 + ))} 57 + </TableHeader> 58 + <TableBody> 59 + {table.getRowModel().rows?.length ? ( 60 + table.getRowModel().rows.map((row) => ( 61 + <TableRow 62 + key={row.id} 63 + data-state={row.getIsSelected() && "selected"} 64 + > 65 + {row.getVisibleCells().map((cell) => ( 66 + <TableCell 67 + key={cell.id} 68 + style={{ 69 + ...getColumnPinningStyle({ column: cell.column }), 70 + }} 71 + > 72 + {flexRender( 73 + cell.column.columnDef.cell, 74 + cell.getContext(), 75 + )} 76 + </TableCell> 77 + ))} 78 + </TableRow> 79 + )) 80 + ) : ( 81 + <TableRow> 82 + <TableCell 83 + colSpan={table.getAllColumns().length} 84 + className="h-24 text-center" 85 + > 86 + No results. 87 + </TableCell> 88 + </TableRow> 89 + )} 90 + </TableBody> 91 + </Table> 92 + </div> 93 + <div className="flex flex-col gap-2.5"> 94 + <DataTablePagination table={table} /> 95 + {actionBar && 96 + table.getFilteredSelectedRowModel().rows.length > 0 && 97 + actionBar} 98 + </div> 99 + </div> 100 + ); 101 + }
+220
web/src/components/ui/calendar.tsx
··· 1 + "use client" 2 + 3 + import * as React from "react" 4 + import { 5 + ChevronDownIcon, 6 + ChevronLeftIcon, 7 + ChevronRightIcon, 8 + } from "lucide-react" 9 + import { 10 + DayPicker, 11 + getDefaultClassNames, 12 + type DayButton, 13 + } from "react-day-picker" 14 + 15 + import { cn } from "@/lib/utils" 16 + import { Button, buttonVariants } from "@/components/ui/button" 17 + 18 + function Calendar({ 19 + className, 20 + classNames, 21 + showOutsideDays = true, 22 + captionLayout = "label", 23 + buttonVariant = "ghost", 24 + formatters, 25 + components, 26 + ...props 27 + }: React.ComponentProps<typeof DayPicker> & { 28 + buttonVariant?: React.ComponentProps<typeof Button>["variant"] 29 + }) { 30 + const defaultClassNames = getDefaultClassNames() 31 + 32 + return ( 33 + <DayPicker 34 + showOutsideDays={showOutsideDays} 35 + className={cn( 36 + "bg-background group/calendar p-3 [--cell-size:--spacing(8)] [[data-slot=card-content]_&]:bg-transparent [[data-slot=popover-content]_&]:bg-transparent", 37 + String.raw`rtl:**:[.rdp-button\_next>svg]:rotate-180`, 38 + String.raw`rtl:**:[.rdp-button\_previous>svg]:rotate-180`, 39 + className 40 + )} 41 + captionLayout={captionLayout} 42 + formatters={{ 43 + formatMonthDropdown: (date) => 44 + date.toLocaleString("default", { month: "short" }), 45 + ...formatters, 46 + }} 47 + classNames={{ 48 + root: cn("w-fit", defaultClassNames.root), 49 + months: cn( 50 + "flex gap-4 flex-col md:flex-row relative", 51 + defaultClassNames.months 52 + ), 53 + month: cn("flex flex-col w-full gap-4", defaultClassNames.month), 54 + nav: cn( 55 + "flex items-center gap-1 w-full absolute top-0 inset-x-0 justify-between", 56 + defaultClassNames.nav 57 + ), 58 + button_previous: cn( 59 + buttonVariants({ variant: buttonVariant }), 60 + "size-(--cell-size) aria-disabled:opacity-50 p-0 select-none", 61 + defaultClassNames.button_previous 62 + ), 63 + button_next: cn( 64 + buttonVariants({ variant: buttonVariant }), 65 + "size-(--cell-size) aria-disabled:opacity-50 p-0 select-none", 66 + defaultClassNames.button_next 67 + ), 68 + month_caption: cn( 69 + "flex items-center justify-center h-(--cell-size) w-full px-(--cell-size)", 70 + defaultClassNames.month_caption 71 + ), 72 + dropdowns: cn( 73 + "w-full flex items-center text-sm font-medium justify-center h-(--cell-size) gap-1.5", 74 + defaultClassNames.dropdowns 75 + ), 76 + dropdown_root: cn( 77 + "relative has-focus:border-ring border border-input shadow-xs has-focus:ring-ring/50 has-focus:ring-[3px] rounded-md", 78 + defaultClassNames.dropdown_root 79 + ), 80 + dropdown: cn( 81 + "absolute bg-popover inset-0 opacity-0", 82 + defaultClassNames.dropdown 83 + ), 84 + caption_label: cn( 85 + "select-none font-medium", 86 + captionLayout === "label" 87 + ? "text-sm" 88 + : "rounded-md pl-2 pr-1 flex items-center gap-1 text-sm h-8 [&>svg]:text-muted-foreground [&>svg]:size-3.5", 89 + defaultClassNames.caption_label 90 + ), 91 + table: "w-full border-collapse", 92 + weekdays: cn("flex", defaultClassNames.weekdays), 93 + weekday: cn( 94 + "text-muted-foreground rounded-md flex-1 font-normal text-[0.8rem] select-none", 95 + defaultClassNames.weekday 96 + ), 97 + week: cn("flex w-full mt-2", defaultClassNames.week), 98 + week_number_header: cn( 99 + "select-none w-(--cell-size)", 100 + defaultClassNames.week_number_header 101 + ), 102 + week_number: cn( 103 + "text-[0.8rem] select-none text-muted-foreground", 104 + defaultClassNames.week_number 105 + ), 106 + day: cn( 107 + "relative w-full h-full p-0 text-center [&:last-child[data-selected=true]_button]:rounded-r-md group/day aspect-square select-none", 108 + props.showWeekNumber 109 + ? "[&:nth-child(2)[data-selected=true]_button]:rounded-l-md" 110 + : "[&:first-child[data-selected=true]_button]:rounded-l-md", 111 + defaultClassNames.day 112 + ), 113 + range_start: cn( 114 + "rounded-l-md bg-accent", 115 + defaultClassNames.range_start 116 + ), 117 + range_middle: cn("rounded-none", defaultClassNames.range_middle), 118 + range_end: cn("rounded-r-md bg-accent", defaultClassNames.range_end), 119 + today: cn( 120 + "bg-accent text-accent-foreground rounded-md data-[selected=true]:rounded-none", 121 + defaultClassNames.today 122 + ), 123 + outside: cn( 124 + "text-muted-foreground aria-selected:text-muted-foreground", 125 + defaultClassNames.outside 126 + ), 127 + disabled: cn( 128 + "text-muted-foreground opacity-50", 129 + defaultClassNames.disabled 130 + ), 131 + hidden: cn("invisible", defaultClassNames.hidden), 132 + ...classNames, 133 + }} 134 + components={{ 135 + Root: ({ className, rootRef, ...props }) => { 136 + return ( 137 + <div 138 + data-slot="calendar" 139 + ref={rootRef} 140 + className={cn(className)} 141 + {...props} 142 + /> 143 + ) 144 + }, 145 + Chevron: ({ className, orientation, ...props }) => { 146 + if (orientation === "left") { 147 + return ( 148 + <ChevronLeftIcon className={cn("size-4", className)} {...props} /> 149 + ) 150 + } 151 + 152 + if (orientation === "right") { 153 + return ( 154 + <ChevronRightIcon 155 + className={cn("size-4", className)} 156 + {...props} 157 + /> 158 + ) 159 + } 160 + 161 + return ( 162 + <ChevronDownIcon className={cn("size-4", className)} {...props} /> 163 + ) 164 + }, 165 + DayButton: CalendarDayButton, 166 + WeekNumber: ({ children, ...props }) => { 167 + return ( 168 + <td {...props}> 169 + <div className="flex size-(--cell-size) items-center justify-center text-center"> 170 + {children} 171 + </div> 172 + </td> 173 + ) 174 + }, 175 + ...components, 176 + }} 177 + {...props} 178 + /> 179 + ) 180 + } 181 + 182 + function CalendarDayButton({ 183 + className, 184 + day, 185 + modifiers, 186 + ...props 187 + }: React.ComponentProps<typeof DayButton>) { 188 + const defaultClassNames = getDefaultClassNames() 189 + 190 + const ref = React.useRef<HTMLButtonElement>(null) 191 + React.useEffect(() => { 192 + if (modifiers.focused) ref.current?.focus() 193 + }, [modifiers.focused]) 194 + 195 + return ( 196 + <Button 197 + ref={ref} 198 + variant="ghost" 199 + size="icon" 200 + data-day={day.date.toLocaleDateString()} 201 + data-selected-single={ 202 + modifiers.selected && 203 + !modifiers.range_start && 204 + !modifiers.range_end && 205 + !modifiers.range_middle 206 + } 207 + data-range-start={modifiers.range_start} 208 + data-range-end={modifiers.range_end} 209 + data-range-middle={modifiers.range_middle} 210 + className={cn( 211 + "data-[selected-single=true]:bg-primary data-[selected-single=true]:text-primary-foreground data-[range-middle=true]:bg-accent data-[range-middle=true]:text-accent-foreground data-[range-start=true]:bg-primary data-[range-start=true]:text-primary-foreground data-[range-end=true]:bg-primary data-[range-end=true]:text-primary-foreground group-data-[focused=true]/day:border-ring group-data-[focused=true]/day:ring-ring/50 dark:hover:text-accent-foreground flex aspect-square size-auto w-full min-w-(--cell-size) flex-col gap-1 leading-none font-normal group-data-[focused=true]/day:relative group-data-[focused=true]/day:z-10 group-data-[focused=true]/day:ring-[3px] data-[range-end=true]:rounded-md data-[range-end=true]:rounded-r-md data-[range-middle=true]:rounded-none data-[range-start=true]:rounded-md data-[range-start=true]:rounded-l-md [&>span]:text-xs [&>span]:opacity-70", 212 + defaultClassNames.day, 213 + className 214 + )} 215 + {...props} 216 + /> 217 + ) 218 + } 219 + 220 + export { Calendar, CalendarDayButton }
+184
web/src/components/ui/command.tsx
··· 1 + "use client" 2 + 3 + import * as React from "react" 4 + import { Command as CommandPrimitive } from "cmdk" 5 + import { SearchIcon } from "lucide-react" 6 + 7 + import { cn } from "@/lib/utils" 8 + import { 9 + Dialog, 10 + DialogContent, 11 + DialogDescription, 12 + DialogHeader, 13 + DialogTitle, 14 + } from "@/components/ui/dialog" 15 + 16 + function Command({ 17 + className, 18 + ...props 19 + }: React.ComponentProps<typeof CommandPrimitive>) { 20 + return ( 21 + <CommandPrimitive 22 + data-slot="command" 23 + className={cn( 24 + "bg-popover text-popover-foreground flex h-full w-full flex-col overflow-hidden rounded-md", 25 + className 26 + )} 27 + {...props} 28 + /> 29 + ) 30 + } 31 + 32 + function CommandDialog({ 33 + title = "Command Palette", 34 + description = "Search for a command to run...", 35 + children, 36 + className, 37 + showCloseButton = true, 38 + ...props 39 + }: React.ComponentProps<typeof Dialog> & { 40 + title?: string 41 + description?: string 42 + className?: string 43 + showCloseButton?: boolean 44 + }) { 45 + return ( 46 + <Dialog {...props}> 47 + <DialogHeader className="sr-only"> 48 + <DialogTitle>{title}</DialogTitle> 49 + <DialogDescription>{description}</DialogDescription> 50 + </DialogHeader> 51 + <DialogContent 52 + className={cn("overflow-hidden p-0", className)} 53 + showCloseButton={showCloseButton} 54 + > 55 + <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"> 56 + {children} 57 + </Command> 58 + </DialogContent> 59 + </Dialog> 60 + ) 61 + } 62 + 63 + function CommandInput({ 64 + className, 65 + ...props 66 + }: React.ComponentProps<typeof CommandPrimitive.Input>) { 67 + return ( 68 + <div 69 + data-slot="command-input-wrapper" 70 + className="flex h-9 items-center gap-2 border-b px-3" 71 + > 72 + <SearchIcon className="size-4 shrink-0 opacity-50" /> 73 + <CommandPrimitive.Input 74 + data-slot="command-input" 75 + className={cn( 76 + "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", 77 + className 78 + )} 79 + {...props} 80 + /> 81 + </div> 82 + ) 83 + } 84 + 85 + function CommandList({ 86 + className, 87 + ...props 88 + }: React.ComponentProps<typeof CommandPrimitive.List>) { 89 + return ( 90 + <CommandPrimitive.List 91 + data-slot="command-list" 92 + className={cn( 93 + "max-h-[300px] scroll-py-1 overflow-x-hidden overflow-y-auto", 94 + className 95 + )} 96 + {...props} 97 + /> 98 + ) 99 + } 100 + 101 + function CommandEmpty({ 102 + ...props 103 + }: React.ComponentProps<typeof CommandPrimitive.Empty>) { 104 + return ( 105 + <CommandPrimitive.Empty 106 + data-slot="command-empty" 107 + className="py-6 text-center text-sm" 108 + {...props} 109 + /> 110 + ) 111 + } 112 + 113 + function CommandGroup({ 114 + className, 115 + ...props 116 + }: React.ComponentProps<typeof CommandPrimitive.Group>) { 117 + return ( 118 + <CommandPrimitive.Group 119 + data-slot="command-group" 120 + className={cn( 121 + "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", 122 + className 123 + )} 124 + {...props} 125 + /> 126 + ) 127 + } 128 + 129 + function CommandSeparator({ 130 + className, 131 + ...props 132 + }: React.ComponentProps<typeof CommandPrimitive.Separator>) { 133 + return ( 134 + <CommandPrimitive.Separator 135 + data-slot="command-separator" 136 + className={cn("bg-border -mx-1 h-px", className)} 137 + {...props} 138 + /> 139 + ) 140 + } 141 + 142 + function CommandItem({ 143 + className, 144 + ...props 145 + }: React.ComponentProps<typeof CommandPrimitive.Item>) { 146 + return ( 147 + <CommandPrimitive.Item 148 + data-slot="command-item" 149 + className={cn( 150 + "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", 151 + className 152 + )} 153 + {...props} 154 + /> 155 + ) 156 + } 157 + 158 + function CommandShortcut({ 159 + className, 160 + ...props 161 + }: React.ComponentProps<"span">) { 162 + return ( 163 + <span 164 + data-slot="command-shortcut" 165 + className={cn( 166 + "text-muted-foreground ml-auto text-xs tracking-widest", 167 + className 168 + )} 169 + {...props} 170 + /> 171 + ) 172 + } 173 + 174 + export { 175 + Command, 176 + CommandDialog, 177 + CommandInput, 178 + CommandList, 179 + CommandEmpty, 180 + CommandGroup, 181 + CommandItem, 182 + CommandShortcut, 183 + CommandSeparator, 184 + }
+1 -1
web/src/components/ui/dialog.tsx
··· 61 61 <DialogPrimitive.Content 62 62 data-slot="dialog-content" 63 63 className={cn( 64 - "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)] max-h-[calc(100vh-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 overflow-y-auto rounded-lg border p-6 shadow-lg duration-200 outline-none sm:max-w-lg", 64 + "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 outline-none sm:max-w-lg", 65 65 className 66 66 )} 67 67 {...props}
+89
web/src/components/ui/popover.tsx
··· 1 + "use client" 2 + 3 + import * as React from "react" 4 + import { Popover as PopoverPrimitive } from "radix-ui" 5 + 6 + import { cn } from "@/lib/utils" 7 + 8 + function Popover({ 9 + ...props 10 + }: React.ComponentProps<typeof PopoverPrimitive.Root>) { 11 + return <PopoverPrimitive.Root data-slot="popover" {...props} /> 12 + } 13 + 14 + function PopoverTrigger({ 15 + ...props 16 + }: React.ComponentProps<typeof PopoverPrimitive.Trigger>) { 17 + return <PopoverPrimitive.Trigger data-slot="popover-trigger" {...props} /> 18 + } 19 + 20 + function PopoverContent({ 21 + className, 22 + align = "center", 23 + sideOffset = 4, 24 + ...props 25 + }: React.ComponentProps<typeof PopoverPrimitive.Content>) { 26 + return ( 27 + <PopoverPrimitive.Portal> 28 + <PopoverPrimitive.Content 29 + data-slot="popover-content" 30 + align={align} 31 + sideOffset={sideOffset} 32 + className={cn( 33 + "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", 34 + className 35 + )} 36 + {...props} 37 + /> 38 + </PopoverPrimitive.Portal> 39 + ) 40 + } 41 + 42 + function PopoverAnchor({ 43 + ...props 44 + }: React.ComponentProps<typeof PopoverPrimitive.Anchor>) { 45 + return <PopoverPrimitive.Anchor data-slot="popover-anchor" {...props} /> 46 + } 47 + 48 + function PopoverHeader({ className, ...props }: React.ComponentProps<"div">) { 49 + return ( 50 + <div 51 + data-slot="popover-header" 52 + className={cn("flex flex-col gap-1 text-sm", className)} 53 + {...props} 54 + /> 55 + ) 56 + } 57 + 58 + function PopoverTitle({ className, ...props }: React.ComponentProps<"h2">) { 59 + return ( 60 + <div 61 + data-slot="popover-title" 62 + className={cn("font-medium", className)} 63 + {...props} 64 + /> 65 + ) 66 + } 67 + 68 + function PopoverDescription({ 69 + className, 70 + ...props 71 + }: React.ComponentProps<"p">) { 72 + return ( 73 + <p 74 + data-slot="popover-description" 75 + className={cn("text-muted-foreground", className)} 76 + {...props} 77 + /> 78 + ) 79 + } 80 + 81 + export { 82 + Popover, 83 + PopoverTrigger, 84 + PopoverContent, 85 + PopoverAnchor, 86 + PopoverHeader, 87 + PopoverTitle, 88 + PopoverDescription, 89 + }
+63
web/src/components/ui/slider.tsx
··· 1 + "use client" 2 + 3 + import * as React from "react" 4 + import { Slider as SliderPrimitive } from "radix-ui" 5 + 6 + import { cn } from "@/lib/utils" 7 + 8 + function Slider({ 9 + className, 10 + defaultValue, 11 + value, 12 + min = 0, 13 + max = 100, 14 + ...props 15 + }: React.ComponentProps<typeof SliderPrimitive.Root>) { 16 + const _values = React.useMemo( 17 + () => 18 + Array.isArray(value) 19 + ? value 20 + : Array.isArray(defaultValue) 21 + ? defaultValue 22 + : [min, max], 23 + [value, defaultValue, min, max] 24 + ) 25 + 26 + return ( 27 + <SliderPrimitive.Root 28 + data-slot="slider" 29 + defaultValue={defaultValue} 30 + value={value} 31 + min={min} 32 + max={max} 33 + className={cn( 34 + "relative flex w-full touch-none items-center select-none data-[disabled]:opacity-50 data-[orientation=vertical]:h-full data-[orientation=vertical]:min-h-44 data-[orientation=vertical]:w-auto data-[orientation=vertical]:flex-col", 35 + className 36 + )} 37 + {...props} 38 + > 39 + <SliderPrimitive.Track 40 + data-slot="slider-track" 41 + className={cn( 42 + "bg-muted relative grow overflow-hidden rounded-full data-[orientation=horizontal]:h-1.5 data-[orientation=horizontal]:w-full data-[orientation=vertical]:h-full data-[orientation=vertical]:w-1.5" 43 + )} 44 + > 45 + <SliderPrimitive.Range 46 + data-slot="slider-range" 47 + className={cn( 48 + "bg-primary absolute data-[orientation=horizontal]:h-full data-[orientation=vertical]:w-full" 49 + )} 50 + /> 51 + </SliderPrimitive.Track> 52 + {Array.from({ length: _values.length }, (_, index) => ( 53 + <SliderPrimitive.Thumb 54 + data-slot="slider-thumb" 55 + key={index} 56 + className="border-primary ring-ring/50 block size-4 shrink-0 rounded-full border bg-white shadow-sm transition-[color,box-shadow] hover:ring-4 focus-visible:ring-4 focus-visible:outline-hidden disabled:pointer-events-none disabled:opacity-50" 57 + /> 58 + ))} 59 + </SliderPrimitive.Root> 60 + ) 61 + } 62 + 63 + export { Slider }
+82
web/src/config/data-table.ts
··· 1 + export type DataTableConfig = typeof dataTableConfig; 2 + 3 + export const dataTableConfig = { 4 + textOperators: [ 5 + { label: "Contains", value: "iLike" as const }, 6 + { label: "Does not contain", value: "notILike" as const }, 7 + { label: "Is", value: "eq" as const }, 8 + { label: "Is not", value: "ne" as const }, 9 + { label: "Is empty", value: "isEmpty" as const }, 10 + { label: "Is not empty", value: "isNotEmpty" as const }, 11 + ], 12 + numericOperators: [ 13 + { label: "Is", value: "eq" as const }, 14 + { label: "Is not", value: "ne" as const }, 15 + { label: "Is less than", value: "lt" as const }, 16 + { label: "Is less than or equal to", value: "lte" as const }, 17 + { label: "Is greater than", value: "gt" as const }, 18 + { label: "Is greater than or equal to", value: "gte" as const }, 19 + { label: "Is between", value: "isBetween" as const }, 20 + { label: "Is empty", value: "isEmpty" as const }, 21 + { label: "Is not empty", value: "isNotEmpty" as const }, 22 + ], 23 + dateOperators: [ 24 + { label: "Is", value: "eq" as const }, 25 + { label: "Is not", value: "ne" as const }, 26 + { label: "Is before", value: "lt" as const }, 27 + { label: "Is after", value: "gt" as const }, 28 + { label: "Is on or before", value: "lte" as const }, 29 + { label: "Is on or after", value: "gte" as const }, 30 + { label: "Is between", value: "isBetween" as const }, 31 + { label: "Is relative to today", value: "isRelativeToToday" as const }, 32 + { label: "Is empty", value: "isEmpty" as const }, 33 + { label: "Is not empty", value: "isNotEmpty" as const }, 34 + ], 35 + selectOperators: [ 36 + { label: "Is", value: "eq" as const }, 37 + { label: "Is not", value: "ne" as const }, 38 + { label: "Is empty", value: "isEmpty" as const }, 39 + { label: "Is not empty", value: "isNotEmpty" as const }, 40 + ], 41 + multiSelectOperators: [ 42 + { label: "Has any of", value: "inArray" as const }, 43 + { label: "Has none of", value: "notInArray" as const }, 44 + { label: "Is empty", value: "isEmpty" as const }, 45 + { label: "Is not empty", value: "isNotEmpty" as const }, 46 + ], 47 + booleanOperators: [ 48 + { label: "Is", value: "eq" as const }, 49 + { label: "Is not", value: "ne" as const }, 50 + ], 51 + sortOrders: [ 52 + { label: "Asc", value: "asc" as const }, 53 + { label: "Desc", value: "desc" as const }, 54 + ], 55 + filterVariants: [ 56 + "text", 57 + "number", 58 + "range", 59 + "date", 60 + "dateRange", 61 + "boolean", 62 + "select", 63 + "multiSelect", 64 + ] as const, 65 + operators: [ 66 + "iLike", 67 + "notILike", 68 + "eq", 69 + "ne", 70 + "inArray", 71 + "notInArray", 72 + "isEmpty", 73 + "isNotEmpty", 74 + "lt", 75 + "lte", 76 + "gt", 77 + "gte", 78 + "isBetween", 79 + "isRelativeToToday", 80 + ] as const, 81 + joinOperators: ["and", "or"] as const, 82 + };
+27
web/src/hooks/use-callback-ref.ts
··· 1 + import * as React from "react"; 2 + 3 + /** 4 + * @see https://github.com/radix-ui/primitives/blob/main/packages/react/use-callback-ref/src/useCallbackRef.tsx 5 + */ 6 + 7 + /** 8 + * A custom hook that converts a callback to a ref to avoid triggering re-renders when passed as a 9 + * prop or avoid re-executing effects when passed as a dependency 10 + */ 11 + function useCallbackRef<T extends (...args: never[]) => unknown>( 12 + callback: T | undefined, 13 + ): T { 14 + const callbackRef = React.useRef(callback); 15 + 16 + React.useEffect(() => { 17 + callbackRef.current = callback; 18 + }); 19 + 20 + // https://github.com/facebook/react/issues/19240 21 + return React.useMemo( 22 + () => ((...args) => callbackRef.current?.(...args)) as T, 23 + [], 24 + ); 25 + } 26 + 27 + export { useCallbackRef };
+316
web/src/hooks/use-data-table.ts
··· 1 + import { 2 + type ColumnFiltersState, 3 + getCoreRowModel, 4 + getFacetedMinMaxValues, 5 + getFacetedRowModel, 6 + getFacetedUniqueValues, 7 + getFilteredRowModel, 8 + getPaginationRowModel, 9 + getSortedRowModel, 10 + type PaginationState, 11 + type RowSelectionState, 12 + type SortingState, 13 + type TableOptions, 14 + type TableState, 15 + type Updater, 16 + useReactTable, 17 + type VisibilityState, 18 + } from "@tanstack/react-table"; 19 + import { 20 + parseAsArrayOf, 21 + parseAsInteger, 22 + parseAsString, 23 + type SingleParser, 24 + type UseQueryStateOptions, 25 + useQueryState, 26 + useQueryStates, 27 + } from "nuqs"; 28 + import * as React from "react"; 29 + 30 + import { useDebouncedCallback } from "@/hooks/use-debounced-callback"; 31 + import { getSortingStateParser } from "@/lib/parsers"; 32 + import type { ExtendedColumnSort, QueryKeys } from "@/types/data-table"; 33 + 34 + const PAGE_KEY = "page"; 35 + const PER_PAGE_KEY = "perPage"; 36 + const SORT_KEY = "sort"; 37 + const FILTERS_KEY = "filters"; 38 + const JOIN_OPERATOR_KEY = "joinOperator"; 39 + const ARRAY_SEPARATOR = ","; 40 + const DEBOUNCE_MS = 300; 41 + const THROTTLE_MS = 50; 42 + 43 + interface UseDataTableProps<TData> 44 + extends Omit< 45 + TableOptions<TData>, 46 + | "state" 47 + | "pageCount" 48 + | "getCoreRowModel" 49 + | "manualFiltering" 50 + | "manualPagination" 51 + | "manualSorting" 52 + >, 53 + Required<Pick<TableOptions<TData>, "pageCount">> { 54 + initialState?: Omit<Partial<TableState>, "sorting"> & { 55 + sorting?: ExtendedColumnSort<TData>[]; 56 + }; 57 + queryKeys?: Partial<QueryKeys>; 58 + history?: "push" | "replace"; 59 + debounceMs?: number; 60 + throttleMs?: number; 61 + clearOnDefault?: boolean; 62 + enableAdvancedFilter?: boolean; 63 + scroll?: boolean; 64 + shallow?: boolean; 65 + startTransition?: React.TransitionStartFunction; 66 + } 67 + 68 + export function useDataTable<TData>(props: UseDataTableProps<TData>) { 69 + const { 70 + columns, 71 + pageCount = -1, 72 + initialState, 73 + queryKeys, 74 + history = "replace", 75 + debounceMs = DEBOUNCE_MS, 76 + throttleMs = THROTTLE_MS, 77 + clearOnDefault = false, 78 + enableAdvancedFilter = false, 79 + scroll = false, 80 + shallow = true, 81 + startTransition, 82 + ...tableProps 83 + } = props; 84 + const pageKey = queryKeys?.page ?? PAGE_KEY; 85 + const perPageKey = queryKeys?.perPage ?? PER_PAGE_KEY; 86 + const sortKey = queryKeys?.sort ?? SORT_KEY; 87 + const filtersKey = queryKeys?.filters ?? FILTERS_KEY; 88 + const joinOperatorKey = queryKeys?.joinOperator ?? JOIN_OPERATOR_KEY; 89 + 90 + const queryStateOptions = React.useMemo< 91 + Omit<UseQueryStateOptions<string>, "parse"> 92 + >( 93 + () => ({ 94 + history, 95 + scroll, 96 + shallow, 97 + throttleMs, 98 + debounceMs, 99 + clearOnDefault, 100 + startTransition, 101 + }), 102 + [ 103 + history, 104 + scroll, 105 + shallow, 106 + throttleMs, 107 + debounceMs, 108 + clearOnDefault, 109 + startTransition, 110 + ], 111 + ); 112 + 113 + const [rowSelection, setRowSelection] = React.useState<RowSelectionState>( 114 + initialState?.rowSelection ?? {}, 115 + ); 116 + const [columnVisibility, setColumnVisibility] = 117 + React.useState<VisibilityState>(initialState?.columnVisibility ?? {}); 118 + 119 + const [page, setPage] = useQueryState( 120 + pageKey, 121 + parseAsInteger.withOptions(queryStateOptions).withDefault(1), 122 + ); 123 + const [perPage, setPerPage] = useQueryState( 124 + perPageKey, 125 + parseAsInteger 126 + .withOptions(queryStateOptions) 127 + .withDefault(initialState?.pagination?.pageSize ?? 10), 128 + ); 129 + 130 + const pagination: PaginationState = React.useMemo(() => { 131 + return { 132 + pageIndex: page - 1, // zero-based index -> one-based index 133 + pageSize: perPage, 134 + }; 135 + }, [page, perPage]); 136 + 137 + const onPaginationChange = React.useCallback( 138 + (updaterOrValue: Updater<PaginationState>) => { 139 + if (typeof updaterOrValue === "function") { 140 + const newPagination = updaterOrValue(pagination); 141 + void setPage(newPagination.pageIndex + 1); 142 + void setPerPage(newPagination.pageSize); 143 + } else { 144 + void setPage(updaterOrValue.pageIndex + 1); 145 + void setPerPage(updaterOrValue.pageSize); 146 + } 147 + }, 148 + [pagination, setPage, setPerPage], 149 + ); 150 + 151 + const columnIds = React.useMemo(() => { 152 + return new Set( 153 + columns.map((column) => column.id).filter(Boolean) as string[], 154 + ); 155 + }, [columns]); 156 + 157 + const [sorting, setSorting] = useQueryState( 158 + sortKey, 159 + getSortingStateParser<TData>(columnIds) 160 + .withOptions(queryStateOptions) 161 + .withDefault(initialState?.sorting ?? []), 162 + ); 163 + 164 + const onSortingChange = React.useCallback( 165 + (updaterOrValue: Updater<SortingState>) => { 166 + if (typeof updaterOrValue === "function") { 167 + const newSorting = updaterOrValue(sorting); 168 + setSorting(newSorting as ExtendedColumnSort<TData>[]); 169 + } else { 170 + setSorting(updaterOrValue as ExtendedColumnSort<TData>[]); 171 + } 172 + }, 173 + [sorting, setSorting], 174 + ); 175 + 176 + const filterableColumns = React.useMemo(() => { 177 + if (enableAdvancedFilter) return []; 178 + 179 + return columns.filter((column) => column.enableColumnFilter); 180 + }, [columns, enableAdvancedFilter]); 181 + 182 + const filterParsers = React.useMemo(() => { 183 + if (enableAdvancedFilter) return {}; 184 + 185 + return filterableColumns.reduce< 186 + Record<string, SingleParser<string> | SingleParser<string[]>> 187 + >((acc, column) => { 188 + if (column.meta?.options) { 189 + acc[column.id ?? ""] = parseAsArrayOf( 190 + parseAsString, 191 + ARRAY_SEPARATOR, 192 + ).withOptions(queryStateOptions); 193 + } else { 194 + acc[column.id ?? ""] = parseAsString.withOptions(queryStateOptions); 195 + } 196 + return acc; 197 + }, {}); 198 + }, [filterableColumns, queryStateOptions, enableAdvancedFilter]); 199 + 200 + const [filterValues, setFilterValues] = useQueryStates(filterParsers); 201 + 202 + const debouncedSetFilterValues = useDebouncedCallback( 203 + (values: typeof filterValues) => { 204 + void setPage(1); 205 + void setFilterValues(values); 206 + }, 207 + debounceMs, 208 + ); 209 + 210 + const initialColumnFilters: ColumnFiltersState = React.useMemo(() => { 211 + if (enableAdvancedFilter) return []; 212 + 213 + return Object.entries(filterValues).reduce<ColumnFiltersState>( 214 + (filters, [key, value]) => { 215 + if (value !== null) { 216 + const processedValue = Array.isArray(value) 217 + ? value 218 + : typeof value === "string" && /[^a-zA-Z0-9]/.test(value) 219 + ? value.split(/[^a-zA-Z0-9]+/).filter(Boolean) 220 + : [value]; 221 + 222 + filters.push({ 223 + id: key, 224 + value: processedValue, 225 + }); 226 + } 227 + return filters; 228 + }, 229 + [], 230 + ); 231 + }, [filterValues, enableAdvancedFilter]); 232 + 233 + const [columnFilters, setColumnFilters] = 234 + React.useState<ColumnFiltersState>(initialColumnFilters); 235 + 236 + const onColumnFiltersChange = React.useCallback( 237 + (updaterOrValue: Updater<ColumnFiltersState>) => { 238 + if (enableAdvancedFilter) return; 239 + 240 + setColumnFilters((prev) => { 241 + const next = 242 + typeof updaterOrValue === "function" 243 + ? updaterOrValue(prev) 244 + : updaterOrValue; 245 + 246 + const filterUpdates = next.reduce< 247 + Record<string, string | string[] | null> 248 + >((acc, filter) => { 249 + if (filterableColumns.find((column) => column.id === filter.id)) { 250 + acc[filter.id] = filter.value as string | string[]; 251 + } 252 + return acc; 253 + }, {}); 254 + 255 + for (const prevFilter of prev) { 256 + if (!next.some((filter) => filter.id === prevFilter.id)) { 257 + filterUpdates[prevFilter.id] = null; 258 + } 259 + } 260 + 261 + debouncedSetFilterValues(filterUpdates); 262 + return next; 263 + }); 264 + }, 265 + [debouncedSetFilterValues, filterableColumns, enableAdvancedFilter], 266 + ); 267 + 268 + const table = useReactTable({ 269 + ...tableProps, 270 + columns, 271 + initialState, 272 + pageCount, 273 + state: { 274 + pagination, 275 + sorting, 276 + columnVisibility, 277 + rowSelection, 278 + columnFilters, 279 + }, 280 + defaultColumn: { 281 + ...tableProps.defaultColumn, 282 + enableColumnFilter: false, 283 + }, 284 + enableRowSelection: true, 285 + onRowSelectionChange: setRowSelection, 286 + onPaginationChange, 287 + onSortingChange, 288 + onColumnFiltersChange, 289 + onColumnVisibilityChange: setColumnVisibility, 290 + getCoreRowModel: getCoreRowModel(), 291 + getFilteredRowModel: getFilteredRowModel(), 292 + getPaginationRowModel: getPaginationRowModel(), 293 + getSortedRowModel: getSortedRowModel(), 294 + getFacetedRowModel: getFacetedRowModel(), 295 + getFacetedUniqueValues: getFacetedUniqueValues(), 296 + getFacetedMinMaxValues: getFacetedMinMaxValues(), 297 + manualPagination: true, 298 + manualSorting: true, 299 + manualFiltering: true, 300 + meta: { 301 + ...tableProps.meta, 302 + queryKeys: { 303 + page: pageKey, 304 + perPage: perPageKey, 305 + sort: sortKey, 306 + filters: filtersKey, 307 + joinOperator: joinOperatorKey, 308 + }, 309 + }, 310 + }); 311 + 312 + return React.useMemo( 313 + () => ({ table, shallow, debounceMs, throttleMs }), 314 + [table, shallow, debounceMs, throttleMs], 315 + ); 316 + }
+28
web/src/hooks/use-debounced-callback.ts
··· 1 + import * as React from "react"; 2 + 3 + import { useCallbackRef } from "@/hooks/use-callback-ref"; 4 + 5 + export function useDebouncedCallback<T extends (...args: never[]) => unknown>( 6 + callback: T, 7 + delay: number, 8 + ) { 9 + const handleCallback = useCallbackRef(callback); 10 + const debounceTimerRef = React.useRef(0); 11 + React.useEffect( 12 + () => () => window.clearTimeout(debounceTimerRef.current), 13 + [], 14 + ); 15 + 16 + const setValue = React.useCallback( 17 + (...args: Parameters<T>) => { 18 + window.clearTimeout(debounceTimerRef.current); 19 + debounceTimerRef.current = window.setTimeout( 20 + () => handleCallback(...args), 21 + delay, 22 + ); 23 + }, 24 + [handleCallback, delay], 25 + ); 26 + 27 + return setValue; 28 + }
+77
web/src/lib/data-table.ts
··· 1 + import type { Column } from "@tanstack/react-table"; 2 + import { dataTableConfig } from "@/config/data-table"; 3 + import type { 4 + ExtendedColumnFilter, 5 + FilterOperator, 6 + FilterVariant, 7 + } from "@/types/data-table"; 8 + 9 + export function getColumnPinningStyle<TData>({ 10 + column, 11 + withBorder = false, 12 + }: { 13 + column: Column<TData>; 14 + withBorder?: boolean; 15 + }): React.CSSProperties { 16 + const isPinned = column.getIsPinned(); 17 + const isLastLeftPinnedColumn = 18 + isPinned === "left" && column.getIsLastColumn("left"); 19 + const isFirstRightPinnedColumn = 20 + isPinned === "right" && column.getIsFirstColumn("right"); 21 + 22 + return { 23 + boxShadow: withBorder 24 + ? isLastLeftPinnedColumn 25 + ? "-4px 0 4px -4px var(--border) inset" 26 + : isFirstRightPinnedColumn 27 + ? "4px 0 4px -4px var(--border) inset" 28 + : undefined 29 + : undefined, 30 + left: isPinned === "left" ? `${column.getStart("left")}px` : undefined, 31 + right: isPinned === "right" ? `${column.getAfter("right")}px` : undefined, 32 + opacity: isPinned ? 0.97 : 1, 33 + position: isPinned ? "sticky" : "relative", 34 + background: isPinned ? "var(--background)" : "var(--background)", 35 + width: column.getSize(), 36 + zIndex: isPinned ? 1 : undefined, 37 + }; 38 + } 39 + 40 + export function getFilterOperators(filterVariant: FilterVariant) { 41 + const operatorMap: Record< 42 + FilterVariant, 43 + { label: string; value: FilterOperator }[] 44 + > = { 45 + text: dataTableConfig.textOperators, 46 + number: dataTableConfig.numericOperators, 47 + range: dataTableConfig.numericOperators, 48 + date: dataTableConfig.dateOperators, 49 + dateRange: dataTableConfig.dateOperators, 50 + boolean: dataTableConfig.booleanOperators, 51 + select: dataTableConfig.selectOperators, 52 + multiSelect: dataTableConfig.multiSelectOperators, 53 + }; 54 + 55 + return operatorMap[filterVariant] ?? dataTableConfig.textOperators; 56 + } 57 + 58 + export function getDefaultFilterOperator(filterVariant: FilterVariant) { 59 + const operators = getFilterOperators(filterVariant); 60 + 61 + return operators[0]?.value ?? (filterVariant === "text" ? "iLike" : "eq"); 62 + } 63 + 64 + export function getValidFilters<TData>( 65 + filters: ExtendedColumnFilter<TData>[], 66 + ): ExtendedColumnFilter<TData>[] { 67 + return filters.filter( 68 + (filter) => 69 + filter.operator === "isEmpty" || 70 + filter.operator === "isNotEmpty" || 71 + (Array.isArray(filter.value) 72 + ? filter.value.length > 0 73 + : filter.value !== "" && 74 + filter.value !== null && 75 + filter.value !== undefined), 76 + ); 77 + }
+17
web/src/lib/format.ts
··· 1 + export function formatDate( 2 + date: Date | string | number | undefined, 3 + opts: Intl.DateTimeFormatOptions = {}, 4 + ) { 5 + if (!date) return ""; 6 + 7 + try { 8 + return new Intl.DateTimeFormat("en-US", { 9 + month: opts.month ?? "long", 10 + day: opts.day ?? "numeric", 11 + year: opts.year ?? "numeric", 12 + ...opts, 13 + }).format(new Date(date)); 14 + } catch (_err) { 15 + return ""; 16 + } 17 + }
+99
web/src/lib/parsers.ts
··· 1 + import { createParser } from "nuqs/server"; 2 + import { z } from "zod"; 3 + 4 + import { dataTableConfig } from "@/config/data-table"; 5 + 6 + import type { 7 + ExtendedColumnFilter, 8 + ExtendedColumnSort, 9 + } from "@/types/data-table"; 10 + 11 + const sortingItemSchema = z.object({ 12 + id: z.string(), 13 + desc: z.boolean(), 14 + }); 15 + 16 + export const getSortingStateParser = <TData>( 17 + columnIds?: string[] | Set<string>, 18 + ) => { 19 + const validKeys = columnIds 20 + ? columnIds instanceof Set 21 + ? columnIds 22 + : new Set(columnIds) 23 + : null; 24 + 25 + return createParser({ 26 + parse: (value) => { 27 + try { 28 + const parsed = JSON.parse(value); 29 + const result = z.array(sortingItemSchema).safeParse(parsed); 30 + 31 + if (!result.success) return null; 32 + 33 + if (validKeys && result.data.some((item) => !validKeys.has(item.id))) { 34 + return null; 35 + } 36 + 37 + return result.data as ExtendedColumnSort<TData>[]; 38 + } catch { 39 + return null; 40 + } 41 + }, 42 + serialize: (value) => JSON.stringify(value), 43 + eq: (a, b) => 44 + a.length === b.length && 45 + a.every( 46 + (item, index) => 47 + item.id === b[index]?.id && item.desc === b[index]?.desc, 48 + ), 49 + }); 50 + }; 51 + 52 + const filterItemSchema = z.object({ 53 + id: z.string(), 54 + value: z.union([z.string(), z.array(z.string())]), 55 + variant: z.enum(dataTableConfig.filterVariants), 56 + operator: z.enum(dataTableConfig.operators), 57 + filterId: z.string(), 58 + }); 59 + 60 + export type FilterItemSchema = z.infer<typeof filterItemSchema>; 61 + 62 + export const getFiltersStateParser = <TData>( 63 + columnIds?: string[] | Set<string>, 64 + ) => { 65 + const validKeys = columnIds 66 + ? columnIds instanceof Set 67 + ? columnIds 68 + : new Set(columnIds) 69 + : null; 70 + 71 + return createParser({ 72 + parse: (value) => { 73 + try { 74 + const parsed = JSON.parse(value); 75 + const result = z.array(filterItemSchema).safeParse(parsed); 76 + 77 + if (!result.success) return null; 78 + 79 + if (validKeys && result.data.some((item) => !validKeys.has(item.id))) { 80 + return null; 81 + } 82 + 83 + return result.data as ExtendedColumnFilter<TData>[]; 84 + } catch { 85 + return null; 86 + } 87 + }, 88 + serialize: (value) => JSON.stringify(value), 89 + eq: (a, b) => 90 + a.length === b.length && 91 + a.every( 92 + (filter, index) => 93 + filter.id === b[index]?.id && 94 + filter.value === b[index]?.value && 95 + filter.variant === b[index]?.variant && 96 + filter.operator === b[index]?.operator, 97 + ), 98 + }); 99 + };
+53
web/src/types/data-table.ts
··· 1 + import type { ColumnSort, Row, RowData } from "@tanstack/react-table"; 2 + import type { DataTableConfig } from "@/config/data-table"; 3 + import type { FilterItemSchema } from "@/lib/parsers"; 4 + 5 + declare module "@tanstack/react-table" { 6 + // biome-ignore lint/correctness/noUnusedVariables: TData is used in the TableMeta interface 7 + interface TableMeta<TData extends RowData> { 8 + queryKeys?: QueryKeys; 9 + } 10 + 11 + // biome-ignore lint/correctness/noUnusedVariables: TData and TValue are used in the ColumnMeta interface 12 + interface ColumnMeta<TData extends RowData, TValue> { 13 + label?: string; 14 + placeholder?: string; 15 + variant?: FilterVariant; 16 + options?: Option[]; 17 + range?: [number, number]; 18 + unit?: string; 19 + icon?: React.FC<React.SVGProps<SVGSVGElement>>; 20 + } 21 + } 22 + 23 + export interface QueryKeys { 24 + page: string; 25 + perPage: string; 26 + sort: string; 27 + filters: string; 28 + joinOperator: string; 29 + } 30 + 31 + export interface Option { 32 + label: string; 33 + value: string; 34 + count?: number; 35 + icon?: React.FC<React.SVGProps<SVGSVGElement>>; 36 + } 37 + 38 + export type FilterOperator = DataTableConfig["operators"][number]; 39 + export type FilterVariant = DataTableConfig["filterVariants"][number]; 40 + export type JoinOperator = DataTableConfig["joinOperators"][number]; 41 + 42 + export interface ExtendedColumnSort<TData> extends Omit<ColumnSort, "id"> { 43 + id: Extract<keyof TData, string>; 44 + } 45 + 46 + export interface ExtendedColumnFilter<TData> extends FilterItemSchema { 47 + id: Extract<keyof TData, string>; 48 + } 49 + 50 + export interface DataTableRowAction<TData> { 51 + row: Row<TData>; 52 + variant: "update" | "delete"; 53 + }