├── types.d.ts ├── read_me_files ├── datatable.gif └── world_domination.gif ├── src ├── context │ └── index.ts ├── Test.tsx ├── components │ ├── TableBody.tsx │ ├── AddItemRow.tsx │ ├── ChipSelected.tsx │ ├── cells │ │ ├── CellSelect.tsx │ │ ├── CellTemplate.tsx │ │ ├── BooleanCell.tsx │ │ ├── Cell.tsx │ │ ├── CellStringEditor.tsx │ │ ├── ChipEditor.tsx │ │ └── CellSelectEditor.tsx │ ├── DataTable.tsx │ ├── TableHeader.tsx │ ├── TableRow.tsx │ ├── BaseHTML.tsx │ ├── Chip.tsx │ └── RowMenu.tsx ├── schema │ └── dataTable.ts ├── database │ └── index.ts └── controllers │ └── tableController.tsx ├── .dockerignore ├── Dockerfile ├── package.json ├── tsconfig.json ├── .gitignore ├── main.tsx ├── README.md ├── uno.config.ts └── public └── dist ├── scripts.js └── unocss.css /types.d.ts: -------------------------------------------------------------------------------- 1 | declare namespace JSX { 2 | interface HtmlTag { 3 | _?:string; 4 | } 5 | } -------------------------------------------------------------------------------- /read_me_files/datatable.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/markkitz/notion-htmx/HEAD/read_me_files/datatable.gif -------------------------------------------------------------------------------- /read_me_files/world_domination.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/markkitz/notion-htmx/HEAD/read_me_files/world_domination.gif -------------------------------------------------------------------------------- /src/context/index.ts: -------------------------------------------------------------------------------- 1 | import { Elysia } from "elysia"; 2 | import { db } from "../database"; 3 | 4 | export const ctx = new Elysia({ 5 | name: "@app/ctx", 6 | }) 7 | .decorate("db", db); -------------------------------------------------------------------------------- /src/Test.tsx: -------------------------------------------------------------------------------- 1 | export default function Test() { 2 | return ( 3 |
4 |

Hello World

5 |
6 | ); 7 | } -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | Dockerfile* 3 | docker-compose* 4 | .dockerignore 5 | .git 6 | .gitignore 7 | README.md 8 | LICENSE 9 | .vscode 10 | Makefile 11 | helm-charts 12 | .env 13 | .editorconfig 14 | .idea 15 | coverage* 16 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM oven/bun 2 | 3 | WORKDIR /app 4 | 5 | COPY package.json . 6 | COPY bun.lockb . 7 | 8 | RUN bun install 9 | 10 | COPY src src 11 | COPY tsconfig.json . 12 | COPY public public 13 | COPY main.tsx main.tsx 14 | 15 | ENV NODE_ENV production 16 | CMD ["bun", "main.tsx"] 17 | 18 | EXPOSE 3030 19 | -------------------------------------------------------------------------------- /src/components/TableBody.tsx: -------------------------------------------------------------------------------- 1 | import type { Column, Row } from "../schema/dataTable"; 2 | import TableRow from "./TableRow"; 3 | 4 | export default function TableBody({rows, columns, tableId}: {rows: Row[], columns: Column[], tableId: string}) { 5 | return (
6 | {rows.map((row) => ())} 7 |
) 8 | } -------------------------------------------------------------------------------- /src/components/AddItemRow.tsx: -------------------------------------------------------------------------------- 1 | export function AddItemRow({tableId}: {tableId: string}) { 2 | return ( 3 |
8 |
9 |
New
10 |
11 | ) 12 | } -------------------------------------------------------------------------------- /src/components/ChipSelected.tsx: -------------------------------------------------------------------------------- 1 | import type { Color, Column } from "../schema/dataTable"; 2 | import Chip from "./Chip"; 3 | 4 | export default function ChipSelected({ color, text, column, rowId }: { color: Color, text: string, column: Column, rowId: string }) { 5 | return (
13 | 14 |
) 15 | } -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "notion-htmx", 3 | "module": "main.tsx", 4 | "type": "module", 5 | "devDependencies": { 6 | "@types/bun": "latest", 7 | "@unocss/preset-icons": "^0.58.5", 8 | "concurrently": "^8.2.2", 9 | "unocss": "^0.58.5" 10 | }, 11 | "peerDependencies": { 12 | "typescript": "^5.0.0" 13 | }, 14 | "dependencies": { 15 | "@elysiajs/html": "^0.8.0", 16 | "@elysiajs/static": "^0.8.1", 17 | "@iconify-json/mdi": "^1.1.64", 18 | "elysia": "^0.8.16", 19 | "nanoid": "^5.0.5" 20 | }, 21 | "scripts": { 22 | "dev": "concurrently \"bun run --hot main.tsx\" \"bunx --bun unocss --watch\"" 23 | } 24 | } -------------------------------------------------------------------------------- /src/schema/dataTable.ts: -------------------------------------------------------------------------------- 1 | export type DataTable = { 2 | id: string; 3 | columns: Column[]; 4 | rows: Row[]; 5 | } 6 | 7 | export type Column = { 8 | id: string; 9 | title: string; 10 | type: "string" | "select" | "date" | "number" | "boolean"; 11 | width: number; 12 | x: number; 13 | tableId: string; 14 | options?: {text:string, color: Color}[]; 15 | } 16 | export type Row = { 17 | id: string; 18 | cellData: CellData[]; 19 | } 20 | export type CellData = { 21 | columnId: string; 22 | value: string | null; 23 | } 24 | export type Color = "yellow" | "green" | "blue" | "red" | "gray" | "pink" | "orange" | "purple"; -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "lib": ["ESNext"], 4 | "target": "ESNext", 5 | "module": "ESNext", 6 | "moduleDetection": "force", 7 | "jsx": "react", 8 | "jsxFactory": "Html.createElement", 9 | "jsxFragmentFactory": "Html.Fragment", 10 | "allowJs": true, 11 | 12 | /* Bundler mode */ 13 | "moduleResolution": "bundler", 14 | "allowImportingTsExtensions": true, 15 | "verbatimModuleSyntax": true, 16 | "noEmit": true, 17 | 18 | /* Linting */ 19 | "skipLibCheck": true, 20 | "strict": true, 21 | "noFallthroughCasesInSwitch": true, 22 | "forceConsistentCasingInFileNames": true 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | bun.lockb 4 | 5 | # dependencies 6 | /node_modules 7 | /.pnp 8 | .pnp.js 9 | 10 | # testing 11 | /coverage 12 | 13 | # next.js 14 | /.next/ 15 | /out/ 16 | 17 | # production 18 | /build 19 | 20 | # misc 21 | .DS_Store 22 | *.pem 23 | 24 | # debug 25 | npm-debug.log* 26 | yarn-debug.log* 27 | yarn-error.log* 28 | 29 | # local env files 30 | .env.local 31 | .env.development.local 32 | .env.test.local 33 | .env.production.local 34 | 35 | # vercel 36 | .vercel 37 | 38 | **/*.trace 39 | **/*.zip 40 | **/*.tar.gz 41 | **/*.tgz 42 | **/*.log 43 | package-lock.json 44 | **/*.bun -------------------------------------------------------------------------------- /src/components/cells/CellSelect.tsx: -------------------------------------------------------------------------------- 1 | import type { Color, Column } from "../../schema/dataTable"; 2 | import Chip from "../Chip"; 3 | import CellTemplate from "./CellTemplate"; 4 | 5 | export default function CellSelect({ column, rowId, option }: { column: Column, rowId: string, option: {text:string, color:Color} }) { 6 | 7 | return ( 15 | 16 | 17 | 18 | ) 19 | } -------------------------------------------------------------------------------- /main.tsx: -------------------------------------------------------------------------------- 1 | import { Elysia } from "elysia"; 2 | import { html } from "@elysiajs/html"; 3 | import {staticPlugin} from "@elysiajs/static"; 4 | import Test from "./src/Test"; 5 | import { tableController } from "./src/controllers/tableController"; 6 | import { ctx } from "./src/context"; 7 | import DataTable from "./src/components/DataTable"; 8 | import BaseHTML from "./src/components/BaseHTML"; 9 | const app = new Elysia() 10 | .use(html()) 11 | .use(staticPlugin()) 12 | .use(tableController) 13 | .use(ctx) 14 | .get("/", ({db}) => { 15 | const dt = db().getDataTable("table1"); 16 | 17 | return ()}) 18 | .listen(3030); 19 | console.log(`🦊 Listening on ${app.server?.hostname}: ${app.server?.port}`); -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # notion-htmx 2 | 3 | A tutorial that builds a feature rich Notion style data table. 4 | 5 | ![Data Table!](read_me_files/datatable.gif) 6 | 7 | ![Drop Down!](read_me_files/world_domination.gif) 8 | 9 | Tech Stack: 10 | - HTMX 11 | - Bun 12 | - ElysiaJS 13 | - UnoCSS 14 | - Tailwind 15 | - Hyperscript 16 | 17 | This repo is identical to the code developed in the tutorial. 18 | 19 | This project was highly influence by Ethan Niser [the-beth-stack](https://github.com/ethanniser/the-beth-stack). 20 | 21 | 22 | 23 | 24 | ## Watch Tutorial on YouTube 25 | [![Notion HTMX](https://img.youtube.com/vi/WRqeGVL1GaU/0.jpg)](https://www.youtube.com/watch?v=WRqeGVL1GaU) 26 | 27 | 28 | ## To run locally: 29 | 1. bun install 30 | 2. bun dev 31 | -------------------------------------------------------------------------------- /src/components/cells/CellTemplate.tsx: -------------------------------------------------------------------------------- 1 | import type { CellData, Column } from "../../schema/dataTable"; 2 | 3 | export default function CellTemplate(props: { column: Column, rowId: string, noPadding?: boolean, children: JSX.Element | string, id?: string }) { 4 | const { column, children, noPadding, id, ...attrib } = props; 5 | return ( 6 |
14 | {children} 15 |
16 |
17 | ); 18 | } -------------------------------------------------------------------------------- /uno.config.ts: -------------------------------------------------------------------------------- 1 | //import transformerVariantGroup from "@unocss/transformer-variant-group"; 2 | import { defineConfig, presetWind, presetIcons } from "unocss"; //presetIcons, presetWebFonts, 3 | export default defineConfig({ 4 | cli: { 5 | entry: { 6 | patterns: ["src/**/*.{ts,tsx}"], 7 | outFile: "public/dist/unocss.css", 8 | }, 9 | }, 10 | presets: [presetWind(), presetIcons()], 11 | shortcuts:{ 12 | 'progressbar':`h-6 mb-6 overflow-hidden bg-gray-100 rounded-md shadow-inner`, 13 | 'nt-c-0': `h-full flex items-center px-2`, 14 | 'nt-c': `border-l-stone-700 border-l-1 h-full flex items-center px-2`, 15 | 'nt-c-main': `border-l-stone-700 border-l-1 h-full flex items-center px-2 relative font-medium `, 16 | 'nt-c-last': `border-l-stone-700 border-l-1 h-full flex items-center px-2 flex-1`, 17 | 18 | } 19 | //transformers: [transformerVariantGroup()], 20 | }); -------------------------------------------------------------------------------- /src/components/cells/BooleanCell.tsx: -------------------------------------------------------------------------------- 1 | import type { CellData, Column } from "../../schema/dataTable"; 2 | import CellTemplate from "./CellTemplate"; 3 | 4 | export default function BooleanCell({ checked, column, rowId }: { checked: boolean, column: Column, rowId: string }) { 5 | return ( 6 | 14 | 20 | 21 | 22 | ); 23 | } -------------------------------------------------------------------------------- /src/components/cells/Cell.tsx: -------------------------------------------------------------------------------- 1 | import type { CellData, Column } from "../../schema/dataTable"; 2 | import BooleanCell from "./BooleanCell"; 3 | import CellSelect from "./CellSelect"; 4 | import CellTemplate from "./CellTemplate"; 5 | 6 | export default function Cell({ cellData, column, rowId }: { cellData: CellData, column: Column, rowId:string}) { 7 | if(column.type === "boolean") { 8 | return (); 9 | } 10 | if(cellData.value && column.type === "select") { 11 | return( option.text === cellData.value) || {text: cellData.value || "", color: "gray"}} column={column} rowId={rowId} />) 12 | } 13 | return( 19 | {cellData.value || ""} 20 | ); 21 | } 22 | 23 | -------------------------------------------------------------------------------- /src/components/cells/CellStringEditor.tsx: -------------------------------------------------------------------------------- 1 | import type { Column } from "../../schema/dataTable"; 2 | import CellTemplate from "./CellTemplate"; 3 | 4 | export default function CellStringEditor({ value, column, rowId }: { value: string, column: Column, rowId: string }) { 5 | 6 | return ( 11 | 24 | 25 | 26 | ) 27 | } -------------------------------------------------------------------------------- /src/components/cells/ChipEditor.tsx: -------------------------------------------------------------------------------- 1 | import type { Color, Column } from "../../schema/dataTable"; 2 | import ChipSelected from "../ChipSelected"; 3 | 4 | export default function ChipEditor({ column, rowId, text, color }: { column: Column, rowId: string, text: string | null, color: Color | null }) { 5 | return (
6 | {(text && color) && } 7 | 17 |
) 18 | 19 | } -------------------------------------------------------------------------------- /src/components/DataTable.tsx: -------------------------------------------------------------------------------- 1 | import type { DataTable } from "../schema/dataTable"; 2 | import { AddItemRow } from "./AddItemRow"; 3 | import TableBody from "./TableBody"; 4 | import TableHeader from "./TableHeader"; 5 | 6 | export default function DataTable({dataTable}: {dataTable: DataTable}) { 7 | return ( 8 |
9 | 10 | 11 | 12 | 19 | 26 |
27 | ); 28 | } -------------------------------------------------------------------------------- /src/components/TableHeader.tsx: -------------------------------------------------------------------------------- 1 | import type { Column } from "../schema/dataTable"; 2 | 3 | export default function TableHeader({ columns }: { columns: Column[] }) { 4 | return ( 5 |
{columns.map((column) => ( 10 |
17 | 18 | {column.title}
))}
19 | ); 20 | } 21 | 22 | 23 | function ColumnExpanderHandle({ columnId, tableId }: { columnId: string, tableId: string}) { 24 | return (
) 27 | } -------------------------------------------------------------------------------- /src/components/TableRow.tsx: -------------------------------------------------------------------------------- 1 | import type { Column, Row } from "../schema/dataTable"; 2 | import RowMenu from "./RowMenu"; 3 | import Cell from "./cells/Cell"; 4 | import CellStringEditor from "./cells/CellStringEditor"; 5 | 6 | export default function TableRow({ row, columns, tableId, editColumnId }: { row: Row, columns: Column[], tableId: string, editColumnId?: string}) { 7 | return ( 8 |
15 | 16 | {columns.map((column) => { 17 | const cellData = row.cellData.find((cellData) => cellData.columnId === column.id); 18 | if (!cellData) { 19 | throw new Error(`No cell data found for column ${column.id}`); 20 | } 21 | if(column.id === editColumnId){ 22 | return () 23 | } 24 | return ( 25 | 26 | ); 27 | })} 28 |
29 |
) 30 | } -------------------------------------------------------------------------------- /src/components/BaseHTML.tsx: -------------------------------------------------------------------------------- 1 | export default function BaseHTML({ children }: { children: JSX.Element }) { 2 | return ( 3 | 4 | 5 | Elysia 6 | 7 | 8 | 9 | 10 | 11 | 12 | {``} 29 | 30 | 31 |
32 | {children} 33 |
34 | 35 | 36 | ) 37 | } -------------------------------------------------------------------------------- /src/components/cells/CellSelectEditor.tsx: -------------------------------------------------------------------------------- 1 | import type { Column } from "../../schema/dataTable"; 2 | import Chip from "../Chip"; 3 | import ChipSelected from "../ChipSelected"; 4 | import CellTemplate from "./CellTemplate"; 5 | import ChipEditor from "./ChipEditor"; 6 | 7 | export default function CellSelectEditor({ value, column, rowId }: { value: string, column: Column, rowId: string }) { 8 | const selectedOption = column.options?.find((option) => option.text === value) || { text: value || "", color: "gray" }; 9 | return ( 16 |
17 | 25 |
29 | 30 |
Select an option or create a new one
31 | {column.options?.map((option) => ())} 41 |
42 | 43 |
44 | 45 | 46 | 47 |
) 48 | } -------------------------------------------------------------------------------- /src/components/Chip.tsx: -------------------------------------------------------------------------------- 1 | import type { Color } from "../schema/dataTable"; 2 | 3 | export default function Chip({ color, text, onclick }: { color: Color, text: string, onclick?:string}) { 4 | if(onclick){ 5 | return ( 6 | 7 | {text} 8 | 17 | ) 18 | } 19 | return ( 20 | {text} 21 | ) 22 | } 23 | export const selectColors: { [key in Color]: { default: string, btnHover: string, icon: string } } = { 24 | yellow: { default: "bg-yellow-400/10 text-yellow-500 ring-yellow-400/20", btnHover: "hover:bg-yellow-500/10", icon: "stroke-yellow-400/50 group-hover:stroke-yellow-100/75" }, 25 | green: { default: "bg-green-500/10 text-green-400 ring-green-500/20", btnHover: "hover:bg-green-500/10", icon: "stroke-green-400/50 group-hover:stroke-green-100/75" }, 26 | blue: { default: "bg-blue-400/10 text-blue-400 ring-blue-400/30", btnHover: "hover:bg-blue-500/10", icon: "stroke-blue-400/50 group-hover:stroke-blue-100/75" }, 27 | gray: { default: "bg-gray-400/10 text-gray-400 ring-gray-400/20", btnHover: "hover:bg-gray-500/10", icon: "stroke-gray-400/50 group-hover:stroke-gray-100/75" }, 28 | pink: { default: "bg-pink-400/10 text-pink-400 ring-pink-400/20", btnHover: "hover:bg-pink-500/10", icon: "stroke-pink-400/50 group-hover:stroke-pink-100/75" }, 29 | orange: { default: "bg-orange-400/10 text-orange-400 ring-orange-400/20", btnHover: "hover:bg-orange-500/10", icon: "stroke-orange-400/50 group-hover:stroke-orange-100/75" }, 30 | purple: { default: "bg-purple-400/10 text-purple-400 ring-purple-400/20", btnHover: "hover:bg-purple-500/10", icon: "stroke-purple-400/50 group-hover:stroke-purple-100/75" }, 31 | red: { default: "bg-red-400/10 text-red-400 ring-red-400/20", btnHover: "hover:bg-red-500/10", icon: "stroke-red-400/50 group-hover:stroke-red-100/75" }, 32 | }; -------------------------------------------------------------------------------- /src/components/RowMenu.tsx: -------------------------------------------------------------------------------- 1 | export default function RowMenu({ rowId, tableId, isOpen=false }: { rowId: string, tableId: string, isOpen?: boolean}) { 2 | if(isOpen) { 3 | return(
4 |
5 | 6 |
) 7 | } 8 | return ( 9 |
13 |
14 |
) 15 | } 16 | 17 | function RowMenuContent({ rowId, tableId }: { rowId: string, tableId: string }) { 18 | return (<> 19 | 26 | 27 |
28 | 34 | 41 |
42 |
Edited by Joe Smith
43 |
Yesterday at 11:22 AM MST
44 |
45 | 46 | ) 47 | } 48 | 49 | 50 | type RowMenuContextItemProps = { 51 | text: string; 52 | icon: string; 53 | } & JSX.IntrinsicElements['div']; 54 | 55 | function RowMenuContextItem({ text, icon, ...props }: RowMenuContextItemProps) { 56 | return ( 57 |
62 |
63 |
{text}
64 |
65 | ); 66 | } -------------------------------------------------------------------------------- /src/database/index.ts: -------------------------------------------------------------------------------- 1 | import { customAlphabet } from "nanoid"; 2 | import type { Color, Column, DataTable, Row } from "../schema/dataTable"; 3 | import { selectColors } from "../components/Chip"; 4 | 5 | const _dbTables: DataTable[] = []; 6 | export function db() { 7 | return { 8 | getDataTable: (id:string) :DataTable => { 9 | let dbDt = _dbTables.find(t => t.id === id); 10 | if(!dbDt) { 11 | dbDt = generateFakeTableData(id); 12 | _dbTables.push(dbDt); 13 | } 14 | return dbDt; 15 | }, 16 | setCellData: (tableId: string, rowId: string, columnId: string, value: string | null): void => { 17 | const table = _dbTables.find((t) => t.id === tableId); 18 | if (!table) { 19 | throw new Error(`Table with id ${tableId} not found`); 20 | } 21 | const row = table.rows.find((r) => r.id === rowId); 22 | if (!row) { 23 | throw new Error(`Row with id ${rowId} not found`); 24 | } 25 | const cell = row.cellData.find((c) => c.columnId === columnId); 26 | if (!cell) { 27 | throw new Error(`Column with id ${columnId} not found`); 28 | } 29 | cell.value = value; 30 | }, 31 | addColumnOption: (tableId: string, columnId: string, value: string): {text:string, color:Color} => { 32 | const table = _dbTables.find((t) => t.id === tableId); 33 | if (!table) { 34 | throw new Error(`Table with id ${tableId} not found`); 35 | } 36 | const column = table.columns.find((c) => c.id === columnId); 37 | if (!column) { 38 | throw new Error(`Column with id ${columnId} not found`); 39 | } 40 | if (!column.options) { 41 | column.options = []; 42 | } 43 | // check if value already exists 44 | const option = column.options.find((option) => option.text === value); 45 | if (!option) { 46 | const randomColor = Object.keys(selectColors)[Math.floor(Math.random() * Object.keys(selectColors).length)] as keyof(typeof selectColors); 47 | 48 | column.options.push({ text: value, color: randomColor }); 49 | return {text: value, color: randomColor}; 50 | 51 | } 52 | return option; 53 | }, 54 | addRow: (tableId: string): Row => { 55 | const table = _dbTables.find((t) => t.id === tableId); 56 | if (!table) { 57 | throw new Error(`Table with id ${tableId} not found`); 58 | } 59 | const nanoid = customAlphabet('1234567890abcdef', 10); 60 | const rowId = nanoid(); 61 | const newRow: Row = { 62 | id: rowId, 63 | cellData: table.columns.map((c) => { 64 | return { 65 | columnId: c.id, 66 | value: null, 67 | }; 68 | }), 69 | }; 70 | table.rows.push(newRow); 71 | return newRow; 72 | }, 73 | deleteRow: (tableId: string, rowId: string): void => { 74 | const table = _dbTables.find((t) => t.id === tableId); 75 | if (!table) { 76 | throw new Error(`Table with id ${tableId} not found`); 77 | } 78 | const rowIdx = table.rows.findIndex((r) => r.id === rowId); 79 | if (rowIdx === -1) { 80 | throw new Error(`Row with id ${rowId} not found`); 81 | } 82 | table.rows.splice(rowIdx, 1); 83 | }, 84 | duplicateRow: (tableId: string, rowId: string): Row => { 85 | const table = _dbTables.find((t) => t.id === tableId); 86 | if (!table) { 87 | throw new Error(`Table with id ${tableId} not found`); 88 | } 89 | const row = table.rows.find((r) => r.id === rowId); 90 | if (!row) { 91 | throw new Error(`Row with id ${rowId} not found`); 92 | } 93 | const nanoid = customAlphabet('1234567890abcdef', 10); 94 | const newRowId = nanoid(); 95 | const newRow: Row = { 96 | id: newRowId, 97 | cellData: row.cellData.map((c) => { 98 | return { 99 | columnId: c.columnId, 100 | value: c.value, 101 | }; 102 | }), 103 | }; 104 | table.rows.push(newRow); 105 | return newRow; 106 | }, 107 | sortRows: (tableId: string, orderedIds: string[]): DataTable => { 108 | const table = _dbTables.find((t) => t.id === tableId); 109 | if (!table) { 110 | throw new Error(`Table with id ${tableId} not found`); 111 | } 112 | table.rows.sort((a, b) => { 113 | return orderedIds.indexOf(a.id) - orderedIds.indexOf(b.id); 114 | }); 115 | 116 | return table; 117 | }, 118 | updateColumns: (tableId: string, columns: Column[]): DataTable => { 119 | const table = _dbTables.find((t) => t.id === tableId); 120 | if (!table) { 121 | throw new Error(`Table with id ${tableId} not found`); 122 | } 123 | table.columns = columns; 124 | return table; 125 | } 126 | } 127 | } 128 | 129 | 130 | export function generateFakeTableData(tableId: string): DataTable { 131 | 132 | const nanoid = customAlphabet('1234567890abcdef', 10) 133 | 134 | const columns: Column[] = [ 135 | { id: `${tableId}_done`, x:0, width: 100, title: "Done", type: "boolean", tableId }, 136 | { id: `${tableId}_todo`, x:100, width: 200, title: "Todo", type: "string", tableId }, 137 | { id: `${tableId}_project`, x:300, width: 100, title: "Project", type: "select", tableId, options: [{text: "Grocery", color: "yellow"}, {text: "Family", color: "green"}, {text: "Health", color: "blue"}]}, 138 | { id: `${tableId}_status`, x:400, width: 100, title: "Status", type: "select", tableId, options: [{text: "Today", color: "green"}, {text: "Tomorrow", color: "blue"}]} 139 | ]; 140 | const cellValues: Record = { 141 | [`${tableId}_done`]: ["true", "false", "true", "false", "false", "false"], 142 | [`${tableId}_todo`]: ["Buy milk", "Buy eggs", "Buy bread", "Buy cheese", "Visit Mom", "Workout"], 143 | [`${tableId}_project`]: ["Grocery", "Grocery", "Grocery", "Grocery", "Family", "Health"], 144 | [`${tableId}_status`]: ["Today", "Today", "Today", "Today", "Tomorrow", "Tomorrow"], 145 | }; 146 | const rowIds = Array.from({ length: 6 }, () => nanoid()); 147 | const rows: Row[] = rowIds.map((id) => { 148 | return { 149 | id, 150 | tableId, 151 | cellData: columns.map((c) => { 152 | return { 153 | columnId: c.id, 154 | value: cellValues[c.id][rowIds.indexOf(id)], 155 | }; 156 | }), 157 | }; 158 | }); 159 | 160 | return { id: tableId, columns, rows }; 161 | 162 | } -------------------------------------------------------------------------------- /public/dist/scripts.js: -------------------------------------------------------------------------------- 1 | htmx.onLoad(function(content) { 2 | var sortables = content.querySelectorAll(".sortable"); 3 | 4 | for (var i = 0; i < sortables.length; i++) { 5 | var sortable = sortables[i]; 6 | const tableId = sortable.getAttribute("data-tableId"); 7 | var sortableInstance = new Sortable(sortable, { 8 | animation: 150, 9 | ghostClass: 'row-being-dragged', 10 | handle: ".drag-handle", 11 | 12 | 13 | onEnd: function (evt) { 14 | document.getElementById(`hdn-rowsort-${tableId}`).click(); 15 | 16 | } 17 | }); 18 | 19 | 20 | sortable.addEventListener("htmx:afterSwap", function() { 21 | sortableInstance.option("disabled", false); 22 | }); 23 | } 24 | }) 25 | function getAllChildrenIds(parentId) { 26 | let parent = document.getElementById(parentId); 27 | let children = parent.children; 28 | let ids = []; 29 | for (let i = 0; i < children.length; i++) { 30 | ids.push(children[i].getAttribute('data-row-id')); 31 | } 32 | return ids; 33 | } 34 | 35 | ///////////////////////// 36 | 37 | function getColumnData(tableId) { 38 | const parentElement = document.getElementById(`th-${tableId}`); 39 | let _columnData = [] 40 | for (let i = 0; i < parentElement.children.length; i++) { 41 | const child = parentElement.children[i]; 42 | const [id, width, x] = [child.getAttribute('id'), parseInt(child.style.width.replace("px", "")), getXTranslation(child.style.transform)]; 43 | _columnData.push({ id, width, x }); 44 | } 45 | 46 | _columnData.sort((a, b) => a.x - b.x); 47 | return _columnData; 48 | } 49 | 50 | function resizeColumn(columnId, tableId, width) { 51 | document.querySelectorAll(`div[data-column='${columnId}']`).forEach((div) => { 52 | div.style.width = `${width}px`; 53 | }); 54 | const _columnData = getColumnData(tableId); 55 | let x = 0; 56 | // reset the x position of each column 57 | for (let i = 0; i < _columnData.length; i++) { 58 | const c = _columnData[i]; 59 | c.x = x; 60 | x += c.width; 61 | let div = document.getElementById(c.id); 62 | div.style = getStyle(c.x, c.width); 63 | } 64 | 65 | } 66 | 67 | 68 | 69 | const MIN_COLUMN_WIDTH = 50; 70 | 71 | function expanderMouseDown(e, columnId, tableId) { 72 | e.stopPropagation(); 73 | e.preventDefault(); 74 | const startingX = e.clientX; 75 | const column = document.querySelector(`#${columnId}`); 76 | const childDiv = column.querySelector('div'); 77 | childDiv.classList.add('bg-blue-500'); 78 | const startingWidthInt = parseInt(column.getAttribute('data-column-width')); 79 | 80 | document.onmousemove = e2 => { 81 | const width = startingWidthInt + e2.clientX - startingX; 82 | resizeColumn(columnId, tableId, width < MIN_COLUMN_WIDTH ? MIN_COLUMN_WIDTH : width) 83 | }; 84 | document.onmouseup = () => { 85 | document.onmousemove = null; 86 | document.onmouseup = null; 87 | childDiv.classList.remove('bg-blue-500'); 88 | column.setAttribute('data-column-width', parseInt(column.style.width.replace('px', ''))); 89 | document.getElementById(`hdn-columns-${tableId}`).click(); 90 | } 91 | } 92 | 93 | function getXTranslation(translateXValue) { 94 | // Extract the numerical part using a regular expression 95 | const match = translateXValue.match(/translateX\(([-\d.]+)px\)/); 96 | // Check if there's a match and return the numerical value 97 | return match ? parseInt(match[1]) : null; 98 | } 99 | function getStyle(x, width, doTransition = false) { 100 | const transform = `transform: translateX(${x}px);`; 101 | const widthStyle = width ? `width:${width}px;` : ""; 102 | const transition = doTransition 103 | ? `transition-property: width, height, left, transform; transition-duration: 270ms; transition-timing-function: ease` 104 | : ""; 105 | return [widthStyle, transform, transition].join(""); 106 | } 107 | ///////////////////////////////////////////// 108 | 109 | function columnMouseDown(e) { 110 | let parentElement = e.target.parentElement; 111 | const tableId = parentElement.getAttribute("data-tableId"); 112 | let _columnData = [] 113 | for (let i = 0; i < parentElement.children.length; i++) { 114 | const child = parentElement.children[i]; 115 | const [id, width, x] = [child.getAttribute('id'), parseInt(child.style.width.replace("px", "")), getXTranslation(child.style.transform)]; 116 | _columnData.push({ id, width, x }); 117 | } 118 | _columnData.sort((a, b) => a.x - b.x); 119 | let _columnIndex = _columnData.findIndex((x) => x.id === e.target.getAttribute("id")); 120 | let _columnMoving = e.target; 121 | let _headerLeftBounds = e.target.parentElement.getBoundingClientRect().left; 122 | let _columnOffset = e.offsetX; 123 | 124 | function handleMouseMove(e) { 125 | if (!_columnMoving) { 126 | return; 127 | } 128 | const colTranslateX = Math.round(e.clientX - _columnOffset - _headerLeftBounds); 129 | let mousePositionOnTrack = e.clientX - _headerLeftBounds; 130 | 131 | let rightXTrigger = 132 | _columnIndex < _columnData.length - 1 ? _columnData[_columnIndex + 1].x : null; 133 | let leftXTrigger = _columnIndex > 0 ? _columnData[_columnIndex - 1].x + _columnData[_columnIndex - 1].width : null; 134 | 135 | if (colTranslateX < 0) { 136 | _columnMoving.style.transform = `translateX(0px)`; 137 | return; 138 | } 139 | if (!rightXTrigger && colTranslateX > _columnData[_columnIndex].x) { 140 | _columnMoving.style.transform = `translateX(${_columnData[_columnIndex].x}px)`; 141 | return; 142 | } 143 | if ( 144 | !!rightXTrigger && 145 | mousePositionOnTrack > rightXTrigger 146 | ) { 147 | moveColumn("right"); 148 | return; 149 | } 150 | else if ( 151 | !!leftXTrigger && 152 | mousePositionOnTrack < leftXTrigger 153 | ) { 154 | moveColumn("left"); 155 | 156 | } 157 | _columnMoving.style.transform = `translateX(${colTranslateX}px)`; 158 | 159 | } 160 | function handleMouseUp() { 161 | 162 | // Remove listeners on mouse up 163 | window.removeEventListener("mousemove", handleMouseMove); 164 | window.removeEventListener("mouseup", handleMouseUp); 165 | 166 | _columnMoving = null; 167 | for (let i = 0; i < _columnData.length; i++) { 168 | const c = _columnData[i]; 169 | const div = document.getElementById(c.id); 170 | div.style = getStyle(c.x, c.width); 171 | } 172 | document.getElementById(`hdn-columns-${tableId}`).click(); 173 | } 174 | function moveColumn(direction) { 175 | const swapColumns = (index1, index2) => { 176 | const temp = _columnData[index1]; 177 | _columnData[index1] = _columnData[index2]; 178 | _columnData[index2] = temp; 179 | }; 180 | 181 | if (direction === "right" && _columnIndex < _columnData.length - 1) { 182 | swapColumns(_columnIndex, _columnIndex + 1); 183 | _columnIndex++; 184 | } else if (direction === "left" && _columnIndex > 0) { 185 | swapColumns(_columnIndex, _columnIndex - 1); 186 | _columnIndex--; 187 | } else { 188 | // Handle invalid direction or column index 189 | return; 190 | } 191 | 192 | let stopX = 0; 193 | _columnData.forEach((column) => { 194 | column.x = stopX; 195 | stopX += column.width; 196 | }); 197 | 198 | const columnMoved = direction === "right" ? _columnData[_columnIndex - 1] : _columnData[_columnIndex + 1]; 199 | const div = document.getElementById(columnMoved.id); 200 | div.style = getStyle(columnMoved.x, columnMoved.width, true); 201 | } 202 | 203 | 204 | 205 | // Add listeners on mouse down 206 | window.addEventListener("mousemove", handleMouseMove); 207 | window.addEventListener("mouseup", handleMouseUp); 208 | } -------------------------------------------------------------------------------- /src/controllers/tableController.tsx: -------------------------------------------------------------------------------- 1 | import { Elysia, t } from "elysia"; 2 | import { ctx } from "../context"; 3 | import Cell from "../components/cells/Cell"; 4 | import CellStringEditor from "../components/cells/CellStringEditor"; 5 | import CellSelectEditor from "../components/cells/CellSelectEditor"; 6 | import CellTemplate from "../components/cells/CellTemplate"; 7 | import ChipEditor from "../components/cells/ChipEditor"; 8 | import TableRow from "../components/TableRow"; 9 | import RowMenu from "../components/RowMenu"; 10 | import DataTable from "../components/DataTable"; 11 | import type { Column } from "../schema/dataTable"; 12 | 13 | export const tableController = new Elysia({ 14 | prefix: "/table" 15 | }) 16 | .use(ctx) 17 | .patch("/:tableId/:rowId/:columnId", ({ params, body, db }) => { 18 | const value = !body.value || body.value === "null" || body.value.trim().length === 0 ? null : body.value; 19 | db().setCellData(params.tableId, params.rowId, params.columnId, value); 20 | const column = db().getDataTable(params.tableId).columns.find((column) => column.id === params.columnId); 21 | if (!column) throw new Error(`Column with id ${params.columnId} not found`); 22 | return ; 23 | }, 24 | { 25 | body: t.Object({ 26 | value: t.Nullable(t.String()) 27 | }), 28 | params: t.Object({ 29 | tableId: t.String(), 30 | rowId: t.String(), 31 | columnId: t.String() 32 | }) 33 | }) 34 | .get("/:tableId/:rowId/:columnId/edit", ({ params, db }) => { 35 | 36 | const dataTable = db().getDataTable(params.tableId); 37 | const column = dataTable.columns.find((column) => column.id === params.columnId); 38 | const row = dataTable.rows.find((row) => row.id === params.rowId); 39 | const value = row?.cellData.find((cell) => cell.columnId === params.columnId)?.value; 40 | if (!column) throw new Error("Column not found"); 41 | if (!row) throw new Error("Row not found"); 42 | if(column.type === "string") 43 | return() 44 | if(column.type === "select") 45 | return() 46 | throw new Error("Column type not supported"); 47 | 48 | }, 49 | { 50 | params: t.Object({ 51 | tableId: t.String(), 52 | rowId: t.String(), 53 | columnId: t.String() 54 | }) 55 | } 56 | ) 57 | .delete("/:tableId/:rowId/:columnId/chip", ({ params, db }) => { 58 | db().setCellData(params.tableId, params.rowId, params.columnId, null); 59 | const column = db().getDataTable(params.tableId).columns.find((column) => column.id === params.columnId); 60 | if(!column) throw new Error(`Column with id ${params.columnId} not found`); 61 | return ; 62 | }, 63 | { 64 | params: t.Object({ 65 | tableId: t.String(), 66 | rowId: t.String(), 67 | columnId: t.String() 68 | }) 69 | } 70 | ) 71 | .get("/:tableId/:rowId/row-menu", ({ params, db }) => { 72 | const table = db().getDataTable(params.tableId); 73 | const row = table.rows.find((row) => row.id === params.rowId); 74 | if (!row) throw new Error(`Row with id ${params.rowId} not found`); 75 | return ; 76 | }, 77 | { 78 | params: t.Object({ 79 | tableId: t.String(), 80 | rowId: t.String() 81 | }) 82 | }) 83 | .get("/:tableId/:rowId/:columnId", ({ params, db }) => { 84 | const column = db().getDataTable(params.tableId).columns.find((column) => column.id === params.columnId); 85 | if (!column) throw new Error(`Column with id ${params.columnId} not found`); 86 | return row.id === params.rowId)?.cellData.find((cell) => cell.columnId === params.columnId) || { columnId: params.columnId, value: "" }} />; 87 | }, 88 | { 89 | params: t.Object({ 90 | tableId: t.String(), 91 | rowId: t.String(), 92 | columnId: t.String() 93 | }) 94 | } 95 | ) 96 | .post("/:tableId/:rowId/:columnId/chip", ({ params, db, body }) => { 97 | const value = body.value; 98 | db().setCellData(params.tableId, params.rowId, params.columnId, value); 99 | const column = db().getDataTable(params.tableId).columns.find((column) => column.id === params.columnId); 100 | if (!column) throw new Error(`Column with id ${params.columnId} not found`); 101 | const option = db().addColumnOption(params.tableId, params.columnId, value); 102 | return ; 103 | 104 | }, 105 | { 106 | params: t.Object({ 107 | tableId: t.String(), 108 | rowId: t.String(), 109 | columnId: t.String() 110 | }), 111 | body: t.Object({ 112 | value: t.String() 113 | }) 114 | }) 115 | .post("/:tableId/row", ({ params, db }) => { 116 | const row = db().addRow(params.tableId); 117 | const columns = db().getDataTable(params.tableId).columns; 118 | //get first string column 119 | const firstStringColumn = columns.find((column) => column.type === "string"); 120 | return(); 121 | }, 122 | { 123 | params: t.Object({ 124 | tableId: t.String(), 125 | }) 126 | }) 127 | .delete("/:tableId/:rowId", ({ params, db, set }) => { 128 | db().deleteRow(params.tableId, params.rowId); 129 | set.status = 204; 130 | return; 131 | } 132 | , 133 | { 134 | params: t.Object({ 135 | tableId: t.String(), 136 | rowId: t.String() 137 | }) 138 | }) 139 | 140 | 141 | .delete("/:tableId/:rowId/row-menu", ({ params, db }) => { 142 | const table = db().getDataTable(params.tableId); 143 | const row = table.rows.find((row) => row.id === params.rowId); 144 | if (!row) throw new Error("Row not found"); 145 | return (); 146 | }, 147 | { 148 | params: t.Object({ 149 | tableId: t.String(), 150 | rowId: t.String() 151 | }) 152 | } 153 | ) 154 | .post("/:tableId/:rowId/duplicate", ({ params, db }) => { 155 | 156 | const newRow = db().duplicateRow(params.tableId, params.rowId); 157 | const dataTable = db().getDataTable(params.tableId); 158 | const columns = dataTable.columns; 159 | const oldRow = dataTable.rows.find((row) => row.id === params.rowId); 160 | //get first string column 161 | const firstStringColumn = columns.find((column) => column.type === "string"); 162 | if(!oldRow || !newRow) throw new Error("Row not found"); 163 | return(<> 164 | 165 | 166 | ); 167 | }) 168 | .post("/:tableId/sort", ({ params, db, body }) => { 169 | const table = db().sortRows(params.tableId, body.item); 170 | 171 | return <>{table.rows.map(r => )} 172 | }, 173 | { 174 | params: t.Object({ 175 | tableId: t.String(), 176 | }), 177 | body: t.Object({ 178 | item: t.Array(t.String()) 179 | }) 180 | 181 | } 182 | ) 183 | .post("/:tableId/column-change", ({ params, db, body }) => { 184 | const _columns = db().getDataTable(params.tableId).columns; 185 | const columns = body.columns.map((c:string) => { 186 | const colNew: Column = JSON.parse(c); 187 | const tempColumn = _columns.find((c) => colNew.id == c.id); 188 | return ({...tempColumn, ...colNew}) 189 | }); 190 | const dataTable = db().updateColumns(params.tableId, columns); 191 | return (); 192 | }, 193 | { 194 | params: t.Object({ 195 | tableId: t.String(), 196 | }), 197 | body: t.Object({ 198 | columns: t.Array(t.String()) 199 | }) 200 | }) 201 | 202 | ; -------------------------------------------------------------------------------- /public/dist/unocss.css: -------------------------------------------------------------------------------- 1 | /* layer: preflights */ 2 | *,::before,::after{--un-rotate:0;--un-rotate-x:0;--un-rotate-y:0;--un-rotate-z:0;--un-scale-x:1;--un-scale-y:1;--un-scale-z:1;--un-skew-x:0;--un-skew-y:0;--un-translate-x:0;--un-translate-y:0;--un-translate-z:0;--un-pan-x: ;--un-pan-y: ;--un-pinch-zoom: ;--un-scroll-snap-strictness:proximity;--un-ordinal: ;--un-slashed-zero: ;--un-numeric-figure: ;--un-numeric-spacing: ;--un-numeric-fraction: ;--un-border-spacing-x:0;--un-border-spacing-y:0;--un-ring-offset-shadow:0 0 rgb(0 0 0 / 0);--un-ring-shadow:0 0 rgb(0 0 0 / 0);--un-shadow-inset: ;--un-shadow:0 0 rgb(0 0 0 / 0);--un-ring-inset: ;--un-ring-offset-width:0px;--un-ring-offset-color:#fff;--un-ring-width:0px;--un-ring-color:rgb(147 197 253 / 0.5);--un-blur: ;--un-brightness: ;--un-contrast: ;--un-drop-shadow: ;--un-grayscale: ;--un-hue-rotate: ;--un-invert: ;--un-saturate: ;--un-sepia: ;--un-backdrop-blur: ;--un-backdrop-brightness: ;--un-backdrop-contrast: ;--un-backdrop-grayscale: ;--un-backdrop-hue-rotate: ;--un-backdrop-invert: ;--un-backdrop-opacity: ;--un-backdrop-saturate: ;--un-backdrop-sepia: ;}::backdrop{--un-rotate:0;--un-rotate-x:0;--un-rotate-y:0;--un-rotate-z:0;--un-scale-x:1;--un-scale-y:1;--un-scale-z:1;--un-skew-x:0;--un-skew-y:0;--un-translate-x:0;--un-translate-y:0;--un-translate-z:0;--un-pan-x: ;--un-pan-y: ;--un-pinch-zoom: ;--un-scroll-snap-strictness:proximity;--un-ordinal: ;--un-slashed-zero: ;--un-numeric-figure: ;--un-numeric-spacing: ;--un-numeric-fraction: ;--un-border-spacing-x:0;--un-border-spacing-y:0;--un-ring-offset-shadow:0 0 rgb(0 0 0 / 0);--un-ring-shadow:0 0 rgb(0 0 0 / 0);--un-shadow-inset: ;--un-shadow:0 0 rgb(0 0 0 / 0);--un-ring-inset: ;--un-ring-offset-width:0px;--un-ring-offset-color:#fff;--un-ring-width:0px;--un-ring-color:rgb(147 197 253 / 0.5);--un-blur: ;--un-brightness: ;--un-contrast: ;--un-drop-shadow: ;--un-grayscale: ;--un-hue-rotate: ;--un-invert: ;--un-saturate: ;--un-sepia: ;--un-backdrop-blur: ;--un-backdrop-brightness: ;--un-backdrop-contrast: ;--un-backdrop-grayscale: ;--un-backdrop-hue-rotate: ;--un-backdrop-invert: ;--un-backdrop-opacity: ;--un-backdrop-saturate: ;--un-backdrop-sepia: ;} 3 | /* layer: icons */ 4 | .i-mdi-add{--un-icon:url("data:image/svg+xml;utf8,%3Csvg viewBox='0 0 24 24' width='1em' height='1em' xmlns='http://www.w3.org/2000/svg' %3E%3Cpath fill='currentColor' d='M19 13h-6v6h-2v-6H5v-2h6V5h2v6h6z'/%3E%3C/svg%3E");-webkit-mask:var(--un-icon) no-repeat;mask:var(--un-icon) no-repeat;-webkit-mask-size:100% 100%;mask-size:100% 100%;background-color:currentColor;color:inherit;width:1em;height:1em;} 5 | .i-mdi-content-copy{--un-icon:url("data:image/svg+xml;utf8,%3Csvg viewBox='0 0 24 24' width='1em' height='1em' xmlns='http://www.w3.org/2000/svg' %3E%3Cpath fill='currentColor' d='M19 21H8V7h11m0-2H8a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h11a2 2 0 0 0 2-2V7a2 2 0 0 0-2-2m-3-4H4a2 2 0 0 0-2 2v14h2V3h12z'/%3E%3C/svg%3E");-webkit-mask:var(--un-icon) no-repeat;mask:var(--un-icon) no-repeat;-webkit-mask-size:100% 100%;mask-size:100% 100%;background-color:currentColor;color:inherit;width:1em;height:1em;} 6 | .i-mdi-drag{--un-icon:url("data:image/svg+xml;utf8,%3Csvg viewBox='0 0 24 24' width='1em' height='1em' xmlns='http://www.w3.org/2000/svg' %3E%3Cpath fill='currentColor' d='M7 19v-2h2v2zm4 0v-2h2v2zm4 0v-2h2v2zm-8-4v-2h2v2zm4 0v-2h2v2zm4 0v-2h2v2zm-8-4V9h2v2zm4 0V9h2v2zm4 0V9h2v2zM7 7V5h2v2zm4 0V5h2v2zm4 0V5h2v2z'/%3E%3C/svg%3E");-webkit-mask:var(--un-icon) no-repeat;mask:var(--un-icon) no-repeat;-webkit-mask-size:100% 100%;mask-size:100% 100%;background-color:currentColor;color:inherit;width:1em;height:1em;} 7 | .i-mdi-loading{--un-icon:url("data:image/svg+xml;utf8,%3Csvg viewBox='0 0 24 24' width='1em' height='1em' xmlns='http://www.w3.org/2000/svg' %3E%3Cpath fill='currentColor' d='M12 4V2A10 10 0 0 0 2 12h2a8 8 0 0 1 8-8'/%3E%3C/svg%3E");-webkit-mask:var(--un-icon) no-repeat;mask:var(--un-icon) no-repeat;-webkit-mask-size:100% 100%;mask-size:100% 100%;background-color:currentColor;color:inherit;width:1em;height:1em;} 8 | .i-mdi-trash{--un-icon:url("data:image/svg+xml;utf8,%3Csvg viewBox='0 0 24 24' width='1em' height='1em' xmlns='http://www.w3.org/2000/svg' %3E%3Cpath fill='currentColor' d='M19 4h-3.5l-1-1h-5l-1 1H5v2h14M6 19a2 2 0 0 0 2 2h8a2 2 0 0 0 2-2V7H6z'/%3E%3C/svg%3E");-webkit-mask:var(--un-icon) no-repeat;mask:var(--un-icon) no-repeat;-webkit-mask-size:100% 100%;mask-size:100% 100%;background-color:currentColor;color:inherit;width:1em;height:1em;} 9 | /* layer: default */ 10 | .sr-only{position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border-width:0;} 11 | .absolute{position:absolute;} 12 | .fixed{position:fixed;} 13 | .relative{position:relative;} 14 | .-inset-1{inset:-0.25rem;} 15 | .inset-0{inset:0;} 16 | .left-0{left:0;} 17 | .left-8{left:2rem;} 18 | .right-0{right:0;} 19 | .right-3{right:0.75rem;} 20 | .top-0{top:0;} 21 | .z-10{z-index:10;} 22 | .z-50{z-index:50;} 23 | .z-60{z-index:60;} 24 | .hover\:z-10:hover{z-index:10;} 25 | .-mr-\[3px\]{margin-right:-3px;} 26 | .-mr-1{margin-right:-0.25rem;} 27 | .mb-2{margin-bottom:0.5rem;} 28 | .me{margin-inline-end:1rem;} 29 | .ml-1{margin-left:0.25rem;} 30 | .ml-6{margin-left:1.5rem;} 31 | .mt-2{margin-top:0.5rem;} 32 | .block{display:block;} 33 | .hidden{display:none;} 34 | .h-3\.5{height:0.875rem;} 35 | .h-4{height:1rem;} 36 | .h-5{height:1.25rem;} 37 | .h-8{height:2rem;} 38 | .h-full{height:100%;} 39 | .h-screen{height:100vh;} 40 | .min-h-screen{min-height:100vh;} 41 | .min-w-\[30px\]{min-width:30px;} 42 | .min-w-1\/2{min-width:50%;} 43 | .w-\[225px\]{width:225px;} 44 | .w-\[250px\]{width:250px;} 45 | .w-\[5px\]{width:5px;} 46 | .w-3\.5{width:0.875rem;} 47 | .w-4{width:1rem;} 48 | .w-5{width:1.25rem;} 49 | .w-6{width:1.5rem;} 50 | .w-full{width:100%;} 51 | .flex{display:flex;} 52 | .inline-flex{display:inline-flex;} 53 | .flex-1{flex:1 1 0%;} 54 | .table{display:table;} 55 | .origin-top-right{transform-origin:top right;} 56 | .transform{transform:translateX(var(--un-translate-x)) translateY(var(--un-translate-y)) translateZ(var(--un-translate-z)) rotate(var(--un-rotate)) rotateX(var(--un-rotate-x)) rotateY(var(--un-rotate-y)) rotateZ(var(--un-rotate-z)) skewX(var(--un-skew-x)) skewY(var(--un-skew-y)) scaleX(var(--un-scale-x)) scaleY(var(--un-scale-y)) scaleZ(var(--un-scale-z));} 57 | @keyframes spin{from{transform:rotate(0deg)}to{transform:rotate(360deg)}} 58 | .animate-spin{animation:spin 1s linear infinite;} 59 | .cursor-pointer{cursor:pointer;} 60 | .cursor-grab{cursor:grab;} 61 | .cursor-col-resize{cursor:col-resize;} 62 | .select-none{-webkit-user-select:none;user-select:none;} 63 | .items-center{align-items:center;} 64 | .justify-center{justify-content:center;} 65 | .gap-2{gap:0.5rem;} 66 | .gap-x-0\.5{column-gap:0.125rem;} 67 | .border-0{border-width:0px;} 68 | .focus\:border-0:focus{border-width:0px;} 69 | .border-b-1{border-bottom-width:1px;} 70 | .border-r-1{border-right-width:1px;} 71 | .border-t-1{border-top-width:1px;} 72 | .border-stone-700{--un-border-opacity:1;border-color:rgb(68 64 60 / var(--un-border-opacity));} 73 | .border-b-stone-700{--un-border-opacity:1;--un-border-bottom-opacity:var(--un-border-opacity);border-bottom-color:rgb(68 64 60 / var(--un-border-bottom-opacity));} 74 | .border-r-stone-700{--un-border-opacity:1;--un-border-right-opacity:var(--un-border-opacity);border-right-color:rgb(68 64 60 / var(--un-border-right-opacity));} 75 | .border-t-stone-700{--un-border-opacity:1;--un-border-top-opacity:var(--un-border-opacity);border-top-color:rgb(68 64 60 / var(--un-border-top-opacity));} 76 | .rounded{border-radius:0.25rem;} 77 | .rounded-md{border-radius:0.375rem;} 78 | .rounded-sm{border-radius:0.125rem;} 79 | .bg-blue-400\/10{background-color:rgb(96 165 250 / 0.1);} 80 | .bg-gray-400\/10{background-color:rgb(156 163 175 / 0.1);} 81 | .bg-green-500\/10{background-color:rgb(34 197 94 / 0.1);} 82 | .bg-orange-400\/10{background-color:rgb(251 146 60 / 0.1);} 83 | .bg-pink-400\/10{background-color:rgb(244 114 182 / 0.1);} 84 | .bg-purple-400\/10{background-color:rgb(192 132 252 / 0.1);} 85 | .bg-red-400\/10{background-color:rgb(248 113 113 / 0.1);} 86 | .bg-yellow-400\/10{background-color:rgb(250 204 21 / 0.1);} 87 | .bg-zinc-700{--un-bg-opacity:1;background-color:rgb(63 63 70 / var(--un-bg-opacity));} 88 | .bg-zinc-800{--un-bg-opacity:1;background-color:rgb(39 39 42 / var(--un-bg-opacity));} 89 | .bg-zinc-900{--un-bg-opacity:1;background-color:rgb(24 24 27 / var(--un-bg-opacity));} 90 | .hover\:bg-blue-500\/10:hover{background-color:rgb(59 130 246 / 0.1);} 91 | .hover\:bg-blue-700:hover{--un-bg-opacity:1;background-color:rgb(29 78 216 / var(--un-bg-opacity));} 92 | .hover\:bg-gray-500\/10:hover{background-color:rgb(107 114 128 / 0.1);} 93 | .hover\:bg-green-500\/10:hover{background-color:rgb(34 197 94 / 0.1);} 94 | .hover\:bg-orange-500\/10:hover{background-color:rgb(249 115 22 / 0.1);} 95 | .hover\:bg-pink-500\/10:hover{background-color:rgb(236 72 153 / 0.1);} 96 | .hover\:bg-purple-500\/10:hover{background-color:rgb(168 85 247 / 0.1);} 97 | .hover\:bg-red-500\/10:hover{background-color:rgb(239 68 68 / 0.1);} 98 | .hover\:bg-stone-700:hover{--un-bg-opacity:1;background-color:rgb(68 64 60 / var(--un-bg-opacity));} 99 | .hover\:bg-stone-800:hover{--un-bg-opacity:1;background-color:rgb(41 37 36 / var(--un-bg-opacity));} 100 | .hover\:bg-yellow-500\/10:hover{background-color:rgb(234 179 8 / 0.1);} 101 | .stroke-blue-400\/50{stroke:rgb(96 165 250 / 0.5);} 102 | .stroke-gray-400\/50{stroke:rgb(156 163 175 / 0.5);} 103 | .stroke-green-400\/50{stroke:rgb(74 222 128 / 0.5);} 104 | .stroke-orange-400\/50{stroke:rgb(251 146 60 / 0.5);} 105 | .stroke-pink-400\/50{stroke:rgb(244 114 182 / 0.5);} 106 | .stroke-purple-400\/50{stroke:rgb(192 132 252 / 0.5);} 107 | .stroke-red-400\/50{stroke:rgb(248 113 113 / 0.5);} 108 | .stroke-yellow-400\/50{stroke:rgb(250 204 21 / 0.5);} 109 | .group:hover .group-hover\:stroke-blue-100\/75{stroke:rgb(219 234 254 / 0.75);} 110 | .group:hover .group-hover\:stroke-gray-100\/75{stroke:rgb(243 244 246 / 0.75);} 111 | .group:hover .group-hover\:stroke-green-100\/75{stroke:rgb(220 252 231 / 0.75);} 112 | .group:hover .group-hover\:stroke-orange-100\/75{stroke:rgb(255 237 213 / 0.75);} 113 | .group:hover .group-hover\:stroke-pink-100\/75{stroke:rgb(252 231 243 / 0.75);} 114 | .group:hover .group-hover\:stroke-purple-100\/75{stroke:rgb(243 232 255 / 0.75);} 115 | .group:hover .group-hover\:stroke-red-100\/75{stroke:rgb(254 226 226 / 0.75);} 116 | .group:hover .group-hover\:stroke-yellow-100\/75{stroke:rgb(254 249 195 / 0.75);} 117 | .p-1{padding:0.25rem;} 118 | .p-2{padding:0.5rem;} 119 | .px{padding-left:1rem;padding-right:1rem;} 120 | .px-2{padding-left:0.5rem;padding-right:0.5rem;} 121 | .py-0{padding-top:0;padding-bottom:0;} 122 | .py-1{padding-top:0.25rem;padding-bottom:0.25rem;} 123 | .pb-2{padding-bottom:0.5rem;} 124 | .pl-1{padding-left:0.25rem;} 125 | .pt-10{padding-top:2.5rem;} 126 | .text-4xl{font-size:2.25rem;line-height:2.5rem;} 127 | .text-lg{font-size:1.125rem;line-height:1.75rem;} 128 | .text-sm{font-size:0.875rem;line-height:1.25rem;} 129 | .text-xs{font-size:0.75rem;line-height:1rem;} 130 | .text-blue-400{--un-text-opacity:1;color:rgb(96 165 250 / var(--un-text-opacity));} 131 | .text-gray-400{--un-text-opacity:1;color:rgb(156 163 175 / var(--un-text-opacity));} 132 | .text-green-400{--un-text-opacity:1;color:rgb(74 222 128 / var(--un-text-opacity));} 133 | .text-indigo-600{--un-text-opacity:1;color:rgb(79 70 229 / var(--un-text-opacity));} 134 | .text-orange-400{--un-text-opacity:1;color:rgb(251 146 60 / var(--un-text-opacity));} 135 | .text-pink-400{--un-text-opacity:1;color:rgb(244 114 182 / var(--un-text-opacity));} 136 | .text-purple-400{--un-text-opacity:1;color:rgb(192 132 252 / var(--un-text-opacity));} 137 | .text-red-400{--un-text-opacity:1;color:rgb(248 113 113 / var(--un-text-opacity));} 138 | .text-red-500{--un-text-opacity:1;color:rgb(239 68 68 / var(--un-text-opacity));} 139 | .text-stone-100{--un-text-opacity:1;color:rgb(245 245 244 / var(--un-text-opacity));} 140 | .text-stone-300{--un-text-opacity:1;color:rgb(214 211 209 / var(--un-text-opacity));} 141 | .text-stone-400{--un-text-opacity:1;color:rgb(168 162 158 / var(--un-text-opacity));} 142 | .text-white\/30{color:rgb(255 255 255 / 0.3);} 143 | .text-yellow-500{--un-text-opacity:1;color:rgb(234 179 8 / var(--un-text-opacity));} 144 | .font-bold{font-weight:700;} 145 | .font-medium{font-weight:500;} 146 | .leading-tight{line-height:1.25;} 147 | .accent-indigo-600{--un-accent-opacity:1;accent-color:rgb(79 70 229 / var(--un-accent-opacity));} 148 | .opacity-0{opacity:0;} 149 | .hover\:opacity-80:hover{opacity:0.8;} 150 | .shadow-lg{--un-shadow:var(--un-shadow-inset) 0 10px 15px -3px var(--un-shadow-color, rgb(0 0 0 / 0.1)),var(--un-shadow-inset) 0 4px 6px -4px var(--un-shadow-color, rgb(0 0 0 / 0.1));box-shadow:var(--un-ring-offset-shadow), var(--un-ring-shadow), var(--un-shadow);} 151 | .outline-none{outline:2px solid transparent;outline-offset:2px;} 152 | .focus\:outline-none:focus{outline:2px solid transparent;outline-offset:2px;} 153 | .ring-1{--un-ring-width:1px;--un-ring-offset-shadow:var(--un-ring-inset) 0 0 0 var(--un-ring-offset-width) var(--un-ring-offset-color);--un-ring-shadow:var(--un-ring-inset) 0 0 0 calc(var(--un-ring-width) + var(--un-ring-offset-width)) var(--un-ring-color);box-shadow:var(--un-ring-offset-shadow), var(--un-ring-shadow), var(--un-shadow);} 154 | .ring-black{--un-ring-opacity:1;--un-ring-color:rgb(0 0 0 / var(--un-ring-opacity));} 155 | .ring-blue-400\/30{--un-ring-color:rgb(96 165 250 / 0.3);} 156 | .ring-gray-400\/20{--un-ring-color:rgb(156 163 175 / 0.2);} 157 | .ring-green-500\/20{--un-ring-color:rgb(34 197 94 / 0.2);} 158 | .ring-orange-400\/20{--un-ring-color:rgb(251 146 60 / 0.2);} 159 | .ring-pink-400\/20{--un-ring-color:rgb(244 114 182 / 0.2);} 160 | .ring-purple-400\/20{--un-ring-color:rgb(192 132 252 / 0.2);} 161 | .ring-red-400\/20{--un-ring-color:rgb(248 113 113 / 0.2);} 162 | .ring-yellow-400\/20{--un-ring-color:rgb(250 204 21 / 0.2);} 163 | .focus\:ring-indigo-600:focus{--un-ring-opacity:1;--un-ring-color:rgb(79 70 229 / var(--un-ring-opacity));} 164 | .ring-opacity-5{--un-ring-opacity:0.05;} 165 | .ring-inset{--un-ring-inset:inset;} --------------------------------------------------------------------------------