Bulk commit: November work

This commit is contained in:
2025-11-06 11:45:59 +01:00
parent 5446120d96
commit cca316daf2
61 changed files with 6581 additions and 259 deletions

View File

@ -0,0 +1,426 @@
# ABAC (Attribute-Based Access Control) Database Schema
## Overview
ABAC is a flexible access control model that makes authorization decisions based on attributes of:
- **Subject** (User): who is requesting access
- **Resource** (Object): what is being accessed
- **Action**: what operation is being performed
- **Environment**: contextual information (time, location, etc.)
## Core Database Tables
### 1. Users Table
Stores user information and their attributes.
```sql
CREATE TABLE users (
id SERIAL PRIMARY KEY,
username VARCHAR(255) UNIQUE NOT NULL,
email VARCHAR(255) UNIQUE NOT NULL,
department VARCHAR(100),
role VARCHAR(100),
security_clearance VARCHAR(50),
employee_level INT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
```
### 2. User Attributes Table
Flexible key-value storage for dynamic user attributes.
```sql
CREATE TABLE user_attributes (
id SERIAL PRIMARY KEY,
user_id INT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
attribute_key VARCHAR(100) NOT NULL,
attribute_value TEXT NOT NULL,
attribute_type VARCHAR(50) DEFAULT 'string', -- string, number, boolean, json
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
UNIQUE(user_id, attribute_key)
);
CREATE INDEX idx_user_attributes_user_id ON user_attributes(user_id);
CREATE INDEX idx_user_attributes_key ON user_attributes(attribute_key);
```
### 3. Resources Table
Stores resources (projects, documents, etc.) and their attributes.
```sql
CREATE TABLE resources (
id SERIAL PRIMARY KEY,
resource_type VARCHAR(100) NOT NULL, -- 'project', 'document', 'crm_contact', etc.
resource_id VARCHAR(255) NOT NULL, -- ID of the actual resource
owner_id INT REFERENCES users(id),
classification VARCHAR(50), -- 'public', 'internal', 'confidential', 'secret'
department VARCHAR(100),
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
UNIQUE(resource_type, resource_id)
);
CREATE INDEX idx_resources_type_id ON resources(resource_type, resource_id);
CREATE INDEX idx_resources_owner ON resources(owner_id);
```
### 4. Resource Attributes Table
Flexible key-value storage for dynamic resource attributes.
```sql
CREATE TABLE resource_attributes (
id SERIAL PRIMARY KEY,
resource_id INT NOT NULL REFERENCES resources(id) ON DELETE CASCADE,
attribute_key VARCHAR(100) NOT NULL,
attribute_value TEXT NOT NULL,
attribute_type VARCHAR(50) DEFAULT 'string',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
UNIQUE(resource_id, attribute_key)
);
CREATE INDEX idx_resource_attributes_resource_id ON resource_attributes(resource_id);
CREATE INDEX idx_resource_attributes_key ON resource_attributes(attribute_key);
```
### 5. Policies Table
Stores ABAC policies that define access rules.
```sql
CREATE TABLE policies (
id SERIAL PRIMARY KEY,
name VARCHAR(255) NOT NULL,
description TEXT,
effect VARCHAR(10) NOT NULL CHECK (effect IN ('allow', 'deny')),
priority INT DEFAULT 0, -- Higher priority policies are evaluated first
enabled BOOLEAN DEFAULT true,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX idx_policies_enabled ON policies(enabled);
CREATE INDEX idx_policies_priority ON policies(priority DESC);
```
### 6. Policy Rules Table
Defines the conditions for each policy.
```sql
CREATE TABLE policy_rules (
id SERIAL PRIMARY KEY,
policy_id INT NOT NULL REFERENCES policies(id) ON DELETE CASCADE,
rule_type VARCHAR(50) NOT NULL, -- 'subject', 'resource', 'action', 'environment'
attribute_key VARCHAR(100) NOT NULL,
operator VARCHAR(50) NOT NULL, -- 'equals', 'not_equals', 'contains', 'in', 'greater_than', 'less_than', 'matches_regex'
attribute_value TEXT NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX idx_policy_rules_policy_id ON policy_rules(policy_id);
CREATE INDEX idx_policy_rules_type ON policy_rules(rule_type);
```
### 7. Policy Actions Table
Defines which actions a policy applies to.
```sql
CREATE TABLE policy_actions (
id SERIAL PRIMARY KEY,
policy_id INT NOT NULL REFERENCES policies(id) ON DELETE CASCADE,
action VARCHAR(100) NOT NULL, -- 'read', 'write', 'delete', 'execute', 'approve', etc.
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
UNIQUE(policy_id, action)
);
CREATE INDEX idx_policy_actions_policy_id ON policy_actions(policy_id);
```
### 8. Environment Attributes Table
Stores contextual/environment attributes for policies.
```sql
CREATE TABLE environment_attributes (
id SERIAL PRIMARY KEY,
attribute_key VARCHAR(100) NOT NULL,
attribute_value TEXT NOT NULL,
attribute_type VARCHAR(50) DEFAULT 'string',
description TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
UNIQUE(attribute_key)
);
```
### 9. Access Logs Table
Audit trail for access decisions.
```sql
CREATE TABLE access_logs (
id SERIAL PRIMARY KEY,
user_id INT REFERENCES users(id),
resource_type VARCHAR(100),
resource_id VARCHAR(255),
action VARCHAR(100),
decision VARCHAR(10), -- 'allow', 'deny'
policy_id INT REFERENCES policies(id),
reason TEXT,
ip_address INET,
user_agent TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX idx_access_logs_user_id ON access_logs(user_id);
CREATE INDEX idx_access_logs_resource ON access_logs(resource_type, resource_id);
CREATE INDEX idx_access_logs_created_at ON access_logs(created_at);
```
## Example Data for Your Application
### Example 1: Department-Based Access to Projects
```sql
-- Policy: Allow users to read projects in their department
INSERT INTO policies (name, description, effect, priority)
VALUES ('Department Project Read Access', 'Users can read projects in their department', 'allow', 100);
-- Subject rule: User must be in a department
INSERT INTO policy_rules (policy_id, rule_type, attribute_key, operator, attribute_value)
VALUES (1, 'subject', 'department', 'equals', '${resource.department}');
-- Resource rule: Resource must be a project
INSERT INTO policy_rules (policy_id, rule_type, attribute_key, operator, attribute_value)
VALUES (1, 'resource', 'resource_type', 'equals', 'project');
-- Action: Read
INSERT INTO policy_actions (policy_id, action)
VALUES (1, 'read');
```
### Example 2: Role-Based Access with Clearance Level
```sql
-- Policy: Allow managers to edit projects with clearance level <= their level
INSERT INTO policies (name, description, effect, priority)
VALUES ('Manager Project Edit Access', 'Managers can edit projects within their clearance', 'allow', 90);
-- Subject rule: User must be a manager
INSERT INTO policy_rules (policy_id, rule_type, attribute_key, operator, attribute_value)
VALUES (2, 'subject', 'role', 'equals', 'manager');
-- Subject rule: User clearance must be >= resource clearance
INSERT INTO policy_rules (policy_id, rule_type, attribute_key, operator, attribute_value)
VALUES (2, 'subject', 'security_clearance', 'greater_than_or_equal', '${resource.classification_level}');
-- Resource rule: Resource must be a project
INSERT INTO policy_rules (policy_id, rule_type, attribute_key, operator, attribute_value)
VALUES (2, 'resource', 'resource_type', 'equals', 'project');
-- Action: Write
INSERT INTO policy_actions (policy_id, action)
VALUES (2, 'write'), (2, 'update');
```
### Example 3: Time-Based Access (Environment Attribute)
```sql
-- Policy: Allow access only during business hours
INSERT INTO policies (name, description, effect, priority)
VALUES ('Business Hours Access', 'Access allowed only during business hours', 'allow', 80);
-- Environment rule: Current time must be between 9 AM and 5 PM
INSERT INTO policy_rules (policy_id, rule_type, attribute_key, operator, attribute_value)
VALUES (3, 'environment', 'current_hour', 'between', '9,17');
-- Environment rule: Current day must be weekday
INSERT INTO policy_rules (policy_id, rule_type, attribute_key, operator, attribute_value)
VALUES (3, 'environment', 'day_of_week', 'in', 'Monday,Tuesday,Wednesday,Thursday,Friday');
```
### Example 4: Owner-Based Access
```sql
-- Policy: Resource owners have full access
INSERT INTO policies (name, description, effect, priority)
VALUES ('Owner Full Access', 'Resource owners have full access to their resources', 'allow', 200);
-- Subject rule: User ID must match resource owner ID
INSERT INTO policy_rules (policy_id, rule_type, attribute_key, operator, attribute_value)
VALUES (4, 'subject', 'user_id', 'equals', '${resource.owner_id}');
-- Actions: All
INSERT INTO policy_actions (policy_id, action)
VALUES (4, 'read'), (4, 'write'), (4, 'delete'), (4, 'share');
```
## Integration with Your Existing Schema
### Extending Your Project Table
```sql
-- Add ABAC resource reference to your existing projekt table
ALTER TABLE projekt ADD COLUMN resource_id INT REFERENCES resources(id);
-- Create trigger to automatically create resource entry
CREATE OR REPLACE FUNCTION create_project_resource()
RETURNS TRIGGER AS $$
BEGIN
INSERT INTO resources (resource_type, resource_id, owner_id, classification, department)
VALUES ('project', NEW.id::VARCHAR, NEW.created_by, 'internal', NEW.department)
RETURNING id INTO NEW.resource_id;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER project_resource_trigger
BEFORE INSERT ON projekt
FOR EACH ROW
EXECUTE FUNCTION create_project_resource();
```
## Policy Evaluation Algorithm
The typical flow for evaluating ABAC policies:
1. **Collect Attributes**
- Subject attributes (from user and user_attributes tables)
- Resource attributes (from resources and resource_attributes tables)
- Action being performed
- Environment attributes (time, IP, location, etc.)
2. **Fetch Applicable Policies**
- Get all enabled policies
- Filter by action
- Order by priority (descending)
3. **Evaluate Each Policy**
- Check if all policy rules match
- Use operators to compare attribute values
- Support variable substitution (e.g., ${resource.owner_id})
4. **Make Decision**
- If any "deny" policy matches → DENY
- If any "allow" policy matches → ALLOW
- If no policies match → DENY (default deny)
5. **Log Decision**
- Record in access_logs table
## Example Query: Check Access
```sql
-- Function to check if user has access to a resource
CREATE OR REPLACE FUNCTION check_access(
p_user_id INT,
p_resource_type VARCHAR,
p_resource_id VARCHAR,
p_action VARCHAR
) RETURNS BOOLEAN AS $$
DECLARE
v_has_access BOOLEAN := FALSE;
v_policy RECORD;
v_rules_match BOOLEAN;
BEGIN
-- Get user attributes
-- Get resource attributes
-- Get environment attributes
-- Loop through policies ordered by priority
FOR v_policy IN
SELECT p.*
FROM policies p
INNER JOIN policy_actions pa ON p.id = pa.policy_id
WHERE p.enabled = true
AND pa.action = p_action
ORDER BY p.priority DESC
LOOP
-- Check if all rules match
v_rules_match := evaluate_policy_rules(v_policy.id, p_user_id, p_resource_type, p_resource_id);
IF v_rules_match THEN
IF v_policy.effect = 'deny' THEN
RETURN FALSE; -- Explicit deny
ELSE
v_has_access := TRUE;
END IF;
END IF;
END LOOP;
RETURN v_has_access;
END;
$$ LANGUAGE plpgsql;
```
## Performance Considerations
1. **Indexing**: Ensure proper indexes on frequently queried columns
2. **Caching**: Cache policy evaluations for frequently accessed resources
3. **Materialized Views**: For complex attribute queries
4. **Denormalization**: Consider storing computed attributes for faster access
5. **Policy Compilation**: Pre-compile policies into optimized decision trees
## Advantages of ABAC
1. **Flexibility**: Easy to add new attributes without schema changes
2. **Fine-grained Control**: Policies can be very specific
3. **Dynamic**: Policies can reference runtime attributes
4. **Scalable**: Policies are independent of users and resources
5. **Auditable**: Clear policy definitions and access logs
## Migration Path from RBAC to ABAC
If you currently use Role-Based Access Control (RBAC), you can gradually migrate:
1. Keep existing roles as user attributes
2. Create policies that check role attributes
3. Gradually add more attribute-based rules
4. Eventually phase out simple role checks
## Example for Your Application
```sql
-- Sample data for your project management system
-- User with attributes
INSERT INTO users (id, username, email, department, role, security_clearance, employee_level)
VALUES (1, 'john.doe', 'john@example.com', 'Engineering', 'senior_developer', 'confidential', 3);
INSERT INTO user_attributes (user_id, attribute_key, attribute_value, attribute_type)
VALUES
(1, 'can_approve_budgets', 'true', 'boolean'),
(1, 'max_budget_approval', '50000', 'number'),
(1, 'project_types', '["web", "mobile", "api"]', 'json');
-- Project as resource
INSERT INTO resources (resource_type, resource_id, owner_id, classification, department)
VALUES ('project', '123', 1, 'internal', 'Engineering');
INSERT INTO resource_attributes (resource_id, attribute_key, attribute_value, attribute_type)
VALUES
(1, 'budget', '30000', 'number'),
(1, 'status', 'active', 'string'),
(1, 'project_type', 'web', 'string');
-- Policy: Senior developers can edit active projects in their department
INSERT INTO policies (name, description, effect, priority)
VALUES ('Senior Dev Project Edit', 'Senior developers can edit active projects', 'allow', 100);
INSERT INTO policy_rules (policy_id, rule_type, attribute_key, operator, attribute_value)
VALUES
(5, 'subject', 'role', 'equals', 'senior_developer'),
(5, 'subject', 'department', 'equals', '${resource.department}'),
(5, 'resource', 'resource_type', 'equals', 'project'),
(5, 'resource', 'status', 'equals', 'active');
INSERT INTO policy_actions (policy_id, action)
VALUES (5, 'write'), (5, 'update');
```
## Conclusion
This ABAC schema provides:
- Flexible attribute-based access control
- Clear separation of concerns
- Audit trail
- Easy policy management
- Scalability for complex authorization requirements
You can implement this alongside your existing authentication system and gradually migrate your authorization logic to use ABAC policies.

1654
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -3,7 +3,7 @@
"private": true,
"type": "module",
"scripts": {
"dev": "vite dev --port 3001",
"dev": "vite dev --port 3001 --host",
"start": "node .output/server/index.mjs",
"build": "vite build",
"serve": "vite preview",
@ -13,18 +13,26 @@
"check": "prettier --write . && eslint --fix"
},
"dependencies": {
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/modifiers": "^9.0.0",
"@dnd-kit/sortable": "^10.0.0",
"@dragdroptouch/drag-drop-touch": "^2.0.3",
"@faker-js/faker": "^9.6.0",
"@radix-ui/react-avatar": "^1.1.10",
"@radix-ui/react-checkbox": "^1.3.3",
"@radix-ui/react-collapsible": "^1.1.12",
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-dropdown-menu": "^2.1.16",
"@radix-ui/react-label": "^2.1.7",
"@radix-ui/react-label": "^2.1.8",
"@radix-ui/react-popover": "^1.1.15",
"@radix-ui/react-select": "^2.2.6",
"@radix-ui/react-separator": "^1.1.7",
"@radix-ui/react-separator": "^1.1.8",
"@radix-ui/react-slider": "^1.3.6",
"@radix-ui/react-slot": "^1.2.3",
"@radix-ui/react-slot": "^1.2.4",
"@radix-ui/react-switch": "^1.2.6",
"@radix-ui/react-tabs": "^1.1.13",
"@radix-ui/react-toggle": "^1.1.10",
"@radix-ui/react-toggle-group": "^1.1.11",
"@radix-ui/react-tooltip": "^1.2.8",
"@t3-oss/env-core": "^0.12.0",
"@tailwindcss/vite": "^4.0.6",
@ -38,14 +46,50 @@
"@tanstack/react-router-ssr-query": "^1.131.7",
"@tanstack/react-start": "^1.131.7",
"@tanstack/react-store": "^0.7.0",
"@tanstack/react-table": "^8.21.2",
"@tanstack/react-table": "^8.21.3",
"@tanstack/react-virtual": "^3.13.12",
"@tanstack/router-plugin": "^1.121.2",
"@tanstack/store": "^0.7.0",
"@tanstack/zod-adapter": "^1.133.36",
"@tiptap/extension-blockquote": "^3.4.2",
"@tiptap/extension-bold": "^3.4.2",
"@tiptap/extension-bullet-list": "^3.4.2",
"@tiptap/extension-code": "^3.4.2",
"@tiptap/extension-code-block": "^3.4.2",
"@tiptap/extension-details": "^3.4.2",
"@tiptap/extension-emoji": "^3.4.2",
"@tiptap/extension-hard-break": "^3.4.2",
"@tiptap/extension-heading": "^3.4.2",
"@tiptap/extension-highlight": "^3.4.2",
"@tiptap/extension-horizontal-rule": "^3.4.2",
"@tiptap/extension-italic": "^3.4.2",
"@tiptap/extension-link": "^3.4.2",
"@tiptap/extension-list-item": "^3.4.2",
"@tiptap/extension-mathematics": "^3.4.2",
"@tiptap/extension-mention": "^3.4.2",
"@tiptap/extension-ordered-list": "^3.4.2",
"@tiptap/extension-paragraph": "^3.4.2",
"@tiptap/extension-strike": "^3.4.2",
"@tiptap/extension-subscript": "^3.4.2",
"@tiptap/extension-superscript": "^3.4.2",
"@tiptap/extension-table": "^3.4.2",
"@tiptap/extension-task-item": "^3.4.2",
"@tiptap/extension-task-list": "^3.4.2",
"@tiptap/extension-text-style": "^3.4.2",
"@tiptap/extension-underline": "^3.4.2",
"@tiptap/extensions": "^3.4.2",
"@tiptap/pm": "^3.4.2",
"@tiptap/react": "^3.4.2",
"@tiptap/starter-kit": "^3.4.2",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cmdk": "^1.1.1",
"date-fns": "^4.1.0",
"lucide-react": "^0.476.0",
"react": "^19.0.0",
"react-day-picker": "^9.11.1",
"react-dom": "^19.0.0",
"sonner": "^2.0.7",
"tailwind-merge": "^3.0.2",
"tailwindcss": "^4.0.6",
"tw-animate-css": "^1.3.6",

View File

@ -13,7 +13,6 @@ import {
import { TeamSwitcher } from '../features/Mandant/components/team-switcher'
import { NavMain } from '@/components/nav-main'
import { NavProjects } from '@/components/nav-projects'
import { NavSecondary } from '@/components/nav-secondary'
import { NavUser } from '@/features/Auth/components/nav-user'
import {
@ -22,6 +21,7 @@ import {
SidebarFooter,
SidebarHeader,
} from '@/components/ui/sidebar'
import { NavProjects } from '@/features/Projects/components/list'
const data = {
user: {
@ -54,20 +54,6 @@ const data = {
title: 'Projects',
url: '/projects',
icon: Bot,
items: [
{
title: 'Create',
url: '/projects/create',
},
{
title: 'Current',
url: '/projects/current',
},
{
title: 'Archive',
url: '/projects/archive',
},
],
},
{
title: 'CRM',
@ -133,7 +119,7 @@ const data = {
icon: LifeBuoy,
},
{
title: 'Feedback',
title: 'Wiki',
url: 'https://git.kocoder.xyz/kocoded/vt/wiki',
icon: Send,
},
@ -165,7 +151,7 @@ export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
</SidebarHeader>
<SidebarContent>
<NavMain items={data.navMain} />
<NavProjects projects={data.projects} />
<NavProjects />
<NavSecondary items={data.navSecondary} className="mt-auto" />
</SidebarContent>
<SidebarFooter>

View File

@ -0,0 +1,50 @@
"use client"
import * as React from "react"
import { ChevronDownIcon } from "lucide-react"
import { type DateRange } from "react-day-picker"
import { Button } from "@/components/ui/button"
import { Calendar } from "@/components/ui/calendar"
import { Label } from "@/components/ui/label"
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover"
export default function Calendar23() {
const [range, setRange] = React.useState<DateRange | undefined>(undefined)
return (
<div className="flex flex-col gap-3">
<Label htmlFor="dates" className="px-1">
Select your stay
</Label>
<Popover>
<PopoverTrigger asChild>
<Button
variant="outline"
id="dates"
className="w-56 justify-between font-normal"
>
{range?.from && range?.to
? `${range.from.toLocaleDateString()} - ${range.to.toLocaleDateString()}`
: "Select date"}
<ChevronDownIcon />
</Button>
</PopoverTrigger>
<PopoverContent className="w-auto overflow-hidden p-0" align="start">
<Calendar
mode="range"
selected={range}
captionLayout="dropdown"
onSelect={(range) => {
setRange(range)
}}
/>
</PopoverContent>
</Popover>
</div>
)
}

View File

@ -0,0 +1,66 @@
import { ArrowDown, ArrowUp, ChevronsUpDown, EyeOff } from 'lucide-react'
import type { Column } from '@tanstack/react-table'
import { cn } from '@/lib/utils'
import { Button } from '@/components/ui/button'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'
interface DataTableColumnHeaderProps<TData, TValue>
extends React.HTMLAttributes<HTMLDivElement> {
column: Column<TData, TValue>
title: string
}
export function DataTableColumnHeader<TData, TValue>({
column,
title,
className,
}: DataTableColumnHeaderProps<TData, TValue>) {
if (!column.getCanSort()) {
return <div className={cn(className)}>{title}</div>
}
return (
<div className={cn('flex items-center gap-2', className)}>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="sm"
className="data-[state=open]:bg-accent -ml-3 h-8"
>
<span>{title}</span>
{column.getIsSorted() === 'desc' ? (
<ArrowDown />
) : column.getIsSorted() === 'asc' ? (
<ArrowUp />
) : (
<ChevronsUpDown />
)}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start">
<DropdownMenuItem onClick={() => column.toggleSorting(false)}>
<ArrowUp />
Asc
</DropdownMenuItem>
<DropdownMenuItem onClick={() => column.toggleSorting(true)}>
<ArrowDown />
Desc
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={() => column.toggleVisibility(false)}>
<EyeOff />
Hide
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
)
}

View File

@ -0,0 +1,239 @@
import {
flexRender,
getCoreRowModel,
getFilteredRowModel,
getSortedRowModel,
useReactTable,
} from '@tanstack/react-table'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { Input } from './ui/input'
import {
DropdownMenu,
DropdownMenuCheckboxItem,
DropdownMenuContent,
DropdownMenuTrigger,
} from './ui/dropdown-menu'
import { Button } from './ui/button'
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from './ui/table'
import type { Dispatch, SetStateAction } from 'react'
import type {
ColumnDef,
RowSelectionState,
SortingState,
VisibilityState,
} from '@tanstack/react-table'
import type {
FetchNextPageOptions,
InfiniteData,
InfiniteQueryObserverResult,
} from '@tanstack/react-query'
interface DataTableProps<TData, TValue> {
columns: Array<ColumnDef<TData, TValue>>
sorting: SortingState
setSorting: Dispatch<SetStateAction<SortingState>>
columnVisibility: VisibilityState
setColumnVisibility: Dispatch<SetStateAction<VisibilityState>>
rowSelection: RowSelectionState
setRowSelection: Dispatch<SetStateAction<RowSelectionState>>
data: InfiniteData<TData, unknown> | undefined
fetchNextPage: (
options?: FetchNextPageOptions,
) => Promise<InfiniteQueryObserverResult<InfiniteData<TData, unknown>, Error>>
isFetching: boolean
isLoading: boolean
}
export function DataTable<TData, TValue>({
columns,
sorting,
setSorting,
rowSelection,
setRowSelection,
columnVisibility,
setColumnVisibility,
data,
fetchNextPage,
isFetching,
isLoading,
}: DataTableProps<TData, TValue>) {
const tableContainerRef = useRef<HTMLDivElement>(null)
const flatData = useMemo(() => {
const res = data?.pages.flatMap((page) => page.data) ?? []
return res
}, [data])
const totalDBRowCount = data?.pages[0]?.meta?.totalProjectsCount ?? 0
const totalFetched = flatData.length
// called on scroll and possibly on mount to fetch more data as the user scrolls and reaches bottom of table
const fetchMoreOnBottomReached = useCallback(
(containerRefElement?: HTMLDivElement | null) => {
if (containerRefElement) {
const { scrollHeight, scrollTop, clientHeight } = containerRefElement
// once the user has scrolled within 500px of the bottom of the table, fetch more data if we can
console.log(
scrollHeight,
scrollTop,
clientHeight,
isFetching,
totalFetched,
totalDBRowCount,
)
if (
scrollHeight - scrollTop - clientHeight < 100 &&
!isFetching &&
totalFetched < totalDBRowCount
) {
fetchNextPage()
}
}
},
[fetchNextPage, isFetching, totalFetched, totalDBRowCount],
)
useEffect(() => {
fetchMoreOnBottomReached(tableContainerRef.current)
}, [fetchMoreOnBottomReached])
const table = useReactTable({
data: flatData,
columns,
getCoreRowModel: getCoreRowModel(),
getSortedRowModel: getSortedRowModel(),
getFilteredRowModel: getFilteredRowModel(),
onRowSelectionChange: setRowSelection,
onColumnVisibilityChange: setColumnVisibility,
onSortingChange: setSorting,
state: {
rowSelection,
columnVisibility,
sorting,
},
})
return (
<div className="h-full max-h-full" id="">
<div className="flex items-center py-4">
<Input
placeholder="Filter names..."
value={table.getColumn('name')?.getFilterValue() as string}
onChange={(event) =>
table.getColumn('name')?.setFilterValue(event.target.value)
}
className="max-w-sm"
/>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" className="ml-auto">
Columns
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
{table
.getAllColumns()
.filter((column) => column.getCanHide())
.map((column) => {
return (
<DropdownMenuCheckboxItem
key={column.id}
className="capitalize"
checked={column.getIsVisible()}
onCheckedChange={(value) =>
column.toggleVisibility(!!value)
}
>
{column.id}
</DropdownMenuCheckboxItem>
)
})}
</DropdownMenuContent>
</DropdownMenu>
</div>
<div
className="overflow-auto relative rounded-md border table-height"
onScroll={(e) => fetchMoreOnBottomReached(e.currentTarget)}
ref={tableContainerRef}
>
<Table className="table-fixed">
<TableHeader className="sticky top-0 z-10">
{table.getHeaderGroups().map((headerGroup) => (
<TableRow key={headerGroup.id} className="sticky top-0 z-10">
{headerGroup.headers.map((header) => {
return (
<TableHead
key={header.id}
style={{ width: header.column.getSize() }}
>
{header.isPlaceholder
? null
: flexRender(
header.column.columnDef.header,
header.getContext(),
)}
</TableHead>
)
})}
</TableRow>
))}
</TableHeader>
<TableBody>
{isLoading ? (
<TableRow>
<TableCell
colSpan={columns.length}
className="h-24 text-center"
>
Loading... Please stand by...
</TableCell>
</TableRow>
) : table.getRowModel().rows.length ? (
table.getRowModel().rows.map((row) => (
<TableRow
key={row.id}
data-state={row.getIsSelected() && 'selected'}
data-index={row.index}
className="overflow-hidden"
>
{row.getVisibleCells().map((cell) => (
<TableCell key={cell.id} className="overflow-hidden">
{flexRender(
cell.column.columnDef.cell,
cell.getContext(),
)}
</TableCell>
))}
</TableRow>
))
) : (
<TableRow>
<TableCell
colSpan={columns.length}
className="h-24 text-center"
>
No results.
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</div>
<div className="flex items-center justify-end space-x-2 py-4">
<div className="text-muted-foreground flex-1 text-sm">
{table.getFilteredSelectedRowModel().rows.length} of{' '}
{table.getFilteredRowModel().rows.length} row(s) selected.
{isFetching && <span>Fetching More...</span>}
</div>
</div>
</div>
)
}

View File

@ -0,0 +1,92 @@
import { cn } from '@/lib/utils'
import { Button } from '@/components/ui/button'
import { Card, CardContent } from '@/components/ui/card'
import { Field, FieldDescription, FieldGroup } from '@/components/ui/field'
import { env } from '@/env'
function Logo() {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
width="512"
height="512"
viewBox="0 0 512 512"
fill="none"
stroke="black"
stroke-width="10"
stroke-linecap="round"
stroke-linejoin="round"
>
<line x1="100" y1="80" x2="420" y2="80" />
<polyline points="100,80 160,40 220,80 280,40 340,80 400,40 420,80" />
<circle cx="360" cy="140" r="30" />
<line x1="360" y1="140" x2="360" y2="80" />
<rect x="60" y="160" width="180" height="240" rx="20" />
<rect x="100" y="120" width="100" height="40" rx="10" />
<circle cx="100" cy="200" r="10" />
<line x1="120" y1="200" x2="200" y2="200" />
<circle cx="100" cy="240" r="10" />
<line x1="120" y1="240" x2="200" y2="240" />
<circle cx="100" cy="280" r="10" />
<line x1="120" y1="280" x2="200" y2="280" />
<rect x="280" y="220" width="140" height="140" />
<rect x="300" y="260" width="60" height="60" />
</svg>
)
}
export function LoginForm({
className,
...props
}: React.ComponentProps<'div'>) {
return (
<div className={cn('flex flex-col gap-6', className)} {...props}>
<Card className="overflow-hidden p-0">
<CardContent className="grid p-0 md:grid-cols-2">
<form className="p-6 md:p-8">
<FieldGroup>
<div className="flex flex-col items-center gap-2 text-center">
{/* <img src="/eventory.svg" /> */}
<h1 className="text-2xl font-bold">Welcome back</h1>
<p className="text-muted-foreground text-balance">
Login to your Acme Inc account
</p>
</div>
<Field className="">
<Button variant="outline" type="button" asChild>
<a href={`${env.VITE_BACKEND_URI}/api/auth`}>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
<path
d="M12.152 6.896c-.948 0-2.415-1.078-3.96-1.04-2.04.027-3.91 1.183-4.961 3.014-2.117 3.675-.546 9.103 1.519 12.09 1.013 1.454 2.208 3.09 3.792 3.039 1.52-.065 2.09-.987 3.935-.987 1.831 0 2.35.987 3.96.948 1.637-.026 2.676-1.48 3.676-2.948 1.156-1.688 1.636-3.325 1.662-3.415-.039-.013-3.182-1.221-3.22-4.857-.026-3.04 2.48-4.494 2.597-4.559-1.429-2.09-3.623-2.324-4.39-2.376-2-.156-3.675 1.09-4.61 1.09zM15.53 3.83c.843-1.012 1.4-2.427 1.245-3.83-1.207.052-2.662.805-3.532 1.818-.78.896-1.454 2.338-1.273 3.714 1.338.104 2.715-.688 3.559-1.701"
fill="currentColor"
/>
</svg>
<span className="">Login with Che</span>
</a>
</Button>
</Field>
<FieldDescription className="text-center">
Don&apos;t have an account? <a href="#">Sign up</a>
</FieldDescription>
</FieldGroup>
</form>
<div className="bg-muted relative hidden md:block">
<img
src="/placeholder.svg"
alt="Image"
className="absolute inset-0 h-full w-full object-cover dark:brightness-[0.2] dark:grayscale"
/>
</div>
</CardContent>
</Card>
<FieldDescription className="px-6 text-center">
By clicking continue, you agree to our <a href="#">Terms of Service</a>{' '}
and <a href="#">Privacy Policy</a>.
</FieldDescription>
</div>
)
}

View File

@ -1,6 +1,8 @@
'use client'
import { ChevronRight, type LucideIcon } from 'lucide-react'
import { ChevronRight } from 'lucide-react'
import { Link } from '@tanstack/react-router'
import type { LucideIcon } from 'lucide-react'
import {
Collapsible,
@ -18,21 +20,20 @@ import {
SidebarMenuSubButton,
SidebarMenuSubItem,
} from '@/components/ui/sidebar'
import { Link } from '@tanstack/react-router'
export function NavMain({
items,
}: {
items: {
items: Array<{
title: string
url: string
icon: LucideIcon
isActive?: boolean
items?: {
items?: Array<{
title: string
url: string
}[]
}[]
}>
}>
}) {
return (
<SidebarGroup>
@ -57,7 +58,7 @@ export function NavMain({
</CollapsibleTrigger>
<CollapsibleContent>
<SidebarMenuSub>
{item.items?.map((subItem) => (
{item.items.map((subItem) => (
<SidebarMenuSubItem key={subItem.title}>
<SidebarMenuSubButton asChild>
<Link to={subItem.url}>

View File

@ -0,0 +1,46 @@
import * as React from "react"
import { Slot } from "@radix-ui/react-slot"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const badgeVariants = cva(
"inline-flex items-center justify-center rounded-full border px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-[color,box-shadow] overflow-hidden",
{
variants: {
variant: {
default:
"border-transparent bg-primary text-primary-foreground [a&]:hover:bg-primary/90",
secondary:
"border-transparent bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90",
destructive:
"border-transparent bg-destructive text-white [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
outline:
"text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
},
},
defaultVariants: {
variant: "default",
},
}
)
function Badge({
className,
variant,
asChild = false,
...props
}: React.ComponentProps<"span"> &
VariantProps<typeof badgeVariants> & { asChild?: boolean }) {
const Comp = asChild ? Slot : "span"
return (
<Comp
data-slot="badge"
className={cn(badgeVariants({ variant }), className)}
{...props}
/>
)
}
export { Badge, badgeVariants }

View File

@ -0,0 +1,216 @@
"use client"
import * as React from "react"
import {
ChevronDownIcon,
ChevronLeftIcon,
ChevronRightIcon,
} from "lucide-react"
import { DayButton, DayPicker, getDefaultClassNames } from "react-day-picker"
import { cn } from "@/lib/utils"
import { Button, buttonVariants } from "@/components/ui/button"
function Calendar({
className,
classNames,
showOutsideDays = true,
captionLayout = "label",
buttonVariant = "ghost",
formatters,
components,
...props
}: React.ComponentProps<typeof DayPicker> & {
buttonVariant?: React.ComponentProps<typeof Button>["variant"]
}) {
const defaultClassNames = getDefaultClassNames()
return (
<DayPicker
showOutsideDays={showOutsideDays}
className={cn(
"bg-background group/calendar p-3 [--cell-size:--spacing(8)] [[data-slot=card-content]_&]:bg-transparent [[data-slot=popover-content]_&]:bg-transparent",
String.raw`rtl:**:[.rdp-button\_next>svg]:rotate-180`,
String.raw`rtl:**:[.rdp-button\_previous>svg]:rotate-180`,
className
)}
captionLayout={captionLayout}
formatters={{
formatMonthDropdown: (date) =>
date.toLocaleString("default", { month: "short" }),
...formatters,
}}
classNames={{
root: cn("w-fit", defaultClassNames.root),
months: cn(
"flex gap-4 flex-col md:flex-row relative",
defaultClassNames.months
),
month: cn("flex flex-col w-full gap-4", defaultClassNames.month),
nav: cn(
"flex items-center gap-1 w-full absolute top-0 inset-x-0 justify-between",
defaultClassNames.nav
),
button_previous: cn(
buttonVariants({ variant: buttonVariant }),
"size-(--cell-size) aria-disabled:opacity-50 p-0 select-none",
defaultClassNames.button_previous
),
button_next: cn(
buttonVariants({ variant: buttonVariant }),
"size-(--cell-size) aria-disabled:opacity-50 p-0 select-none",
defaultClassNames.button_next
),
month_caption: cn(
"flex items-center justify-center h-(--cell-size) w-full px-(--cell-size)",
defaultClassNames.month_caption
),
dropdowns: cn(
"w-full flex items-center text-sm font-medium justify-center h-(--cell-size) gap-1.5",
defaultClassNames.dropdowns
),
dropdown_root: cn(
"relative has-focus:border-ring border border-input shadow-xs has-focus:ring-ring/50 has-focus:ring-[3px] rounded-md",
defaultClassNames.dropdown_root
),
dropdown: cn(
"absolute bg-popover inset-0 opacity-0",
defaultClassNames.dropdown
),
caption_label: cn(
"select-none font-medium",
captionLayout === "label"
? "text-sm"
: "rounded-md pl-2 pr-1 flex items-center gap-1 text-sm h-8 [&>svg]:text-muted-foreground [&>svg]:size-3.5",
defaultClassNames.caption_label
),
table: "w-full border-collapse",
weekdays: cn("flex", defaultClassNames.weekdays),
weekday: cn(
"text-muted-foreground rounded-md flex-1 font-normal text-[0.8rem] select-none",
defaultClassNames.weekday
),
week: cn("flex w-full mt-2", defaultClassNames.week),
week_number_header: cn(
"select-none w-(--cell-size)",
defaultClassNames.week_number_header
),
week_number: cn(
"text-[0.8rem] select-none text-muted-foreground",
defaultClassNames.week_number
),
day: cn(
"relative w-full h-full p-0 text-center [&:last-child[data-selected=true]_button]:rounded-r-md group/day aspect-square select-none",
props.showWeekNumber
? "[&:nth-child(2)[data-selected=true]_button]:rounded-l-md"
: "[&:first-child[data-selected=true]_button]:rounded-l-md",
defaultClassNames.day
),
range_start: cn(
"rounded-l-md bg-accent",
defaultClassNames.range_start
),
range_middle: cn("rounded-none", defaultClassNames.range_middle),
range_end: cn("rounded-r-md bg-accent", defaultClassNames.range_end),
today: cn(
"bg-accent text-accent-foreground rounded-md data-[selected=true]:rounded-none",
defaultClassNames.today
),
outside: cn(
"text-muted-foreground aria-selected:text-muted-foreground",
defaultClassNames.outside
),
disabled: cn(
"text-muted-foreground opacity-50",
defaultClassNames.disabled
),
hidden: cn("invisible", defaultClassNames.hidden),
...classNames,
}}
components={{
Root: ({ className, rootRef, ...props }) => {
return (
<div
data-slot="calendar"
ref={rootRef}
className={cn(className)}
{...props}
/>
)
},
Chevron: ({ className, orientation, ...props }) => {
if (orientation === "left") {
return (
<ChevronLeftIcon className={cn("size-4", className)} {...props} />
)
}
if (orientation === "right") {
return (
<ChevronRightIcon
className={cn("size-4", className)}
{...props}
/>
)
}
return (
<ChevronDownIcon className={cn("size-4", className)} {...props} />
)
},
DayButton: CalendarDayButton,
WeekNumber: ({ children, ...props }) => {
return (
<td {...props}>
<div className="flex size-(--cell-size) items-center justify-center text-center">
{children}
</div>
</td>
)
},
...components,
}}
{...props}
/>
)
}
function CalendarDayButton({
className,
day,
modifiers,
...props
}: React.ComponentProps<typeof DayButton>) {
const defaultClassNames = getDefaultClassNames()
const ref = React.useRef<HTMLButtonElement>(null)
React.useEffect(() => {
if (modifiers.focused) ref.current?.focus()
}, [modifiers.focused])
return (
<Button
ref={ref}
variant="ghost"
size="icon"
data-day={day.date.toLocaleDateString()}
data-selected-single={
modifiers.selected &&
!modifiers.range_start &&
!modifiers.range_end &&
!modifiers.range_middle
}
data-range-start={modifiers.range_start}
data-range-end={modifiers.range_end}
data-range-middle={modifiers.range_middle}
className={cn(
"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",
defaultClassNames.day,
className
)}
{...props}
/>
)
}
export { Calendar, CalendarDayButton }

View File

@ -0,0 +1,92 @@
import * as React from "react"
import { cn } from "@/lib/utils"
function Card({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card"
className={cn(
"bg-card text-card-foreground flex flex-col gap-6 rounded-xl border py-6 shadow-sm",
className
)}
{...props}
/>
)
}
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-header"
className={cn(
"@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-2 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6",
className
)}
{...props}
/>
)
}
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-title"
className={cn("leading-none font-semibold", className)}
{...props}
/>
)
}
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-description"
className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
)
}
function CardAction({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-action"
className={cn(
"col-start-2 row-span-2 row-start-1 self-start justify-self-end",
className
)}
{...props}
/>
)
}
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-content"
className={cn("px-6", className)}
{...props}
/>
)
}
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-footer"
className={cn("flex items-center px-6 [.border-t]:pt-6", className)}
{...props}
/>
)
}
export {
Card,
CardHeader,
CardFooter,
CardTitle,
CardAction,
CardDescription,
CardContent,
}

View File

@ -0,0 +1,30 @@
import * as React from "react"
import * as CheckboxPrimitive from "@radix-ui/react-checkbox"
import { CheckIcon } from "lucide-react"
import { cn } from "@/lib/utils"
function Checkbox({
className,
...props
}: React.ComponentProps<typeof CheckboxPrimitive.Root>) {
return (
<CheckboxPrimitive.Root
data-slot="checkbox"
className={cn(
"peer border-input dark:bg-input/30 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground dark:data-[state=checked]:bg-primary data-[state=checked]:border-primary focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive size-4 shrink-0 rounded-[4px] border shadow-xs transition-shadow outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
className
)}
{...props}
>
<CheckboxPrimitive.Indicator
data-slot="checkbox-indicator"
className="grid place-content-center text-current transition-none"
>
<CheckIcon className="size-3.5" />
</CheckboxPrimitive.Indicator>
</CheckboxPrimitive.Root>
)
}
export { Checkbox }

View File

@ -0,0 +1,182 @@
import * as React from "react"
import { Command as CommandPrimitive } from "cmdk"
import { SearchIcon } from "lucide-react"
import { cn } from "@/lib/utils"
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog"
function Command({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive>) {
return (
<CommandPrimitive
data-slot="command"
className={cn(
"bg-popover text-popover-foreground flex h-full w-full flex-col overflow-hidden rounded-md",
className
)}
{...props}
/>
)
}
function CommandDialog({
title = "Command Palette",
description = "Search for a command to run...",
children,
className,
showCloseButton = true,
...props
}: React.ComponentProps<typeof Dialog> & {
title?: string
description?: string
className?: string
showCloseButton?: boolean
}) {
return (
<Dialog {...props}>
<DialogHeader className="sr-only">
<DialogTitle>{title}</DialogTitle>
<DialogDescription>{description}</DialogDescription>
</DialogHeader>
<DialogContent
className={cn("overflow-hidden p-0", className)}
showCloseButton={showCloseButton}
>
<Command className="[&_[cmdk-group-heading]]:text-muted-foreground **:data-[slot=command-input-wrapper]:h-12 [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group]]:px-2 [&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0 [&_[cmdk-input-wrapper]_svg]:h-5 [&_[cmdk-input-wrapper]_svg]:w-5 [&_[cmdk-input]]:h-12 [&_[cmdk-item]]:px-2 [&_[cmdk-item]]:py-3 [&_[cmdk-item]_svg]:h-5 [&_[cmdk-item]_svg]:w-5">
{children}
</Command>
</DialogContent>
</Dialog>
)
}
function CommandInput({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.Input>) {
return (
<div
data-slot="command-input-wrapper"
className="flex h-9 items-center gap-2 border-b px-3"
>
<SearchIcon className="size-4 shrink-0 opacity-50" />
<CommandPrimitive.Input
data-slot="command-input"
className={cn(
"placeholder:text-muted-foreground flex h-10 w-full rounded-md bg-transparent py-3 text-sm outline-hidden disabled:cursor-not-allowed disabled:opacity-50",
className
)}
{...props}
/>
</div>
)
}
function CommandList({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.List>) {
return (
<CommandPrimitive.List
data-slot="command-list"
className={cn(
"max-h-[300px] scroll-py-1 overflow-x-hidden overflow-y-auto",
className
)}
{...props}
/>
)
}
function CommandEmpty({
...props
}: React.ComponentProps<typeof CommandPrimitive.Empty>) {
return (
<CommandPrimitive.Empty
data-slot="command-empty"
className="py-6 text-center text-sm"
{...props}
/>
)
}
function CommandGroup({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.Group>) {
return (
<CommandPrimitive.Group
data-slot="command-group"
className={cn(
"text-foreground [&_[cmdk-group-heading]]:text-muted-foreground overflow-hidden p-1 [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1.5 [&_[cmdk-group-heading]]:text-xs [&_[cmdk-group-heading]]:font-medium",
className
)}
{...props}
/>
)
}
function CommandSeparator({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.Separator>) {
return (
<CommandPrimitive.Separator
data-slot="command-separator"
className={cn("bg-border -mx-1 h-px", className)}
{...props}
/>
)
}
function CommandItem({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.Item>) {
return (
<CommandPrimitive.Item
data-slot="command-item"
className={cn(
"data-[selected=true]:bg-accent data-[selected=true]:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
/>
)
}
function CommandShortcut({
className,
...props
}: React.ComponentProps<"span">) {
return (
<span
data-slot="command-shortcut"
className={cn(
"text-muted-foreground ml-auto text-xs tracking-widest",
className
)}
{...props}
/>
)
}
export {
Command,
CommandDialog,
CommandInput,
CommandList,
CommandEmpty,
CommandGroup,
CommandItem,
CommandShortcut,
CommandSeparator,
}

View File

@ -0,0 +1,141 @@
import * as React from "react"
import * as DialogPrimitive from "@radix-ui/react-dialog"
import { XIcon } from "lucide-react"
import { cn } from "@/lib/utils"
function Dialog({
...props
}: React.ComponentProps<typeof DialogPrimitive.Root>) {
return <DialogPrimitive.Root data-slot="dialog" {...props} />
}
function DialogTrigger({
...props
}: React.ComponentProps<typeof DialogPrimitive.Trigger>) {
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />
}
function DialogPortal({
...props
}: React.ComponentProps<typeof DialogPrimitive.Portal>) {
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />
}
function DialogClose({
...props
}: React.ComponentProps<typeof DialogPrimitive.Close>) {
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />
}
function DialogOverlay({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Overlay>) {
return (
<DialogPrimitive.Overlay
data-slot="dialog-overlay"
className={cn(
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
className
)}
{...props}
/>
)
}
function DialogContent({
className,
children,
showCloseButton = true,
...props
}: React.ComponentProps<typeof DialogPrimitive.Content> & {
showCloseButton?: boolean
}) {
return (
<DialogPortal data-slot="dialog-portal">
<DialogOverlay />
<DialogPrimitive.Content
data-slot="dialog-content"
className={cn(
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg",
className
)}
{...props}
>
{children}
{showCloseButton && (
<DialogPrimitive.Close
data-slot="dialog-close"
className="ring-offset-background focus:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4"
>
<XIcon />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
)}
</DialogPrimitive.Content>
</DialogPortal>
)
}
function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="dialog-header"
className={cn("flex flex-col gap-2 text-center sm:text-left", className)}
{...props}
/>
)
}
function DialogFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="dialog-footer"
className={cn(
"flex flex-col-reverse gap-2 sm:flex-row sm:justify-end",
className
)}
{...props}
/>
)
}
function DialogTitle({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Title>) {
return (
<DialogPrimitive.Title
data-slot="dialog-title"
className={cn("text-lg leading-none font-semibold", className)}
{...props}
/>
)
}
function DialogDescription({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Description>) {
return (
<DialogPrimitive.Description
data-slot="dialog-description"
className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
)
}
export {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogOverlay,
DialogPortal,
DialogTitle,
DialogTrigger,
}

246
src/components/ui/field.tsx Normal file
View File

@ -0,0 +1,246 @@
import { useMemo } from 'react'
import { cva, type VariantProps } from 'class-variance-authority'
import { cn } from '@/lib/utils'
import { Label } from '@/components/ui/label'
import { Separator } from '@/components/ui/separator'
function FieldSet({ className, ...props }: React.ComponentProps<'fieldset'>) {
return (
<fieldset
data-slot="field-set"
className={cn(
'flex flex-col gap-6',
'has-[>[data-slot=checkbox-group]]:gap-3 has-[>[data-slot=radio-group]]:gap-3',
className,
)}
{...props}
/>
)
}
function FieldLegend({
className,
variant = 'legend',
...props
}: React.ComponentProps<'legend'> & { variant?: 'legend' | 'label' }) {
return (
<legend
data-slot="field-legend"
data-variant={variant}
className={cn(
'font-medium',
'data-[variant=legend]:text-base',
'data-[variant=label]:text-sm',
className,
)}
{...props}
/>
)
}
function FieldGroup({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot="field-group"
className={cn(
'group/field-group @container/field-group flex w-full flex-col gap-7 data-[slot=checkbox-group]:gap-3 [&>[data-slot=field-group]]:gap-4',
className,
)}
{...props}
/>
)
}
const fieldVariants = cva(
'group/field flex w-full gap-3 data-[invalid=true]:text-destructive',
{
variants: {
orientation: {
vertical: ['flex-col [&>*]:w-full [&>.sr-only]:w-auto'],
horizontal: [
'flex-row items-center',
'[&>[data-slot=field-label]]:flex-auto',
'has-[>[data-slot=field-content]]:items-start has-[>[data-slot=field-content]]:[&>[role=checkbox],[role=radio]]:mt-px',
],
responsive: [
'flex-col [&>*]:w-full [&>.sr-only]:w-auto @md/field-group:flex-row @md/field-group:items-center @md/field-group:[&>*]:w-auto',
'@md/field-group:[&>[data-slot=field-label]]:flex-auto',
'@md/field-group:has-[>[data-slot=field-content]]:items-start @md/field-group:has-[>[data-slot=field-content]]:[&>[role=checkbox],[role=radio]]:mt-px',
],
},
},
defaultVariants: {
orientation: 'vertical',
},
},
)
function Field({
className,
orientation = 'vertical',
...props
}: React.ComponentProps<'div'> & VariantProps<typeof fieldVariants>) {
return (
<div
role="group"
data-slot="field"
data-orientation={orientation}
className={cn(fieldVariants({ orientation }), className)}
{...props}
/>
)
}
function FieldContent({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot="field-content"
className={cn(
'group/field-content flex flex-1 flex-col gap-1.5 leading-snug',
className,
)}
{...props}
/>
)
}
function FieldLabel({
className,
...props
}: React.ComponentProps<typeof Label>) {
return (
<Label
data-slot="field-label"
className={cn(
'group/field-label peer/field-label flex w-fit gap-2 leading-snug group-data-[disabled=true]/field:opacity-50',
'has-[>[data-slot=field]]:w-full has-[>[data-slot=field]]:flex-col has-[>[data-slot=field]]:rounded-md has-[>[data-slot=field]]:border [&>*]:data-[slot=field]:p-4',
'has-data-[state=checked]:bg-primary/5 has-data-[state=checked]:border-primary dark:has-data-[state=checked]:bg-primary/10',
className,
)}
{...props}
/>
)
}
function FieldTitle({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot="field-label"
className={cn(
'flex w-fit items-center gap-2 text-sm leading-snug font-medium group-data-[disabled=true]/field:opacity-50',
className,
)}
{...props}
/>
)
}
function FieldDescription({ className, ...props }: React.ComponentProps<'p'>) {
return (
<p
data-slot="field-description"
className={cn(
'text-muted-foreground text-sm leading-normal font-normal group-has-[[data-orientation=horizontal]]/field:text-balance',
'last:mt-0 nth-last-2:-mt-1 [[data-variant=legend]+&]:-mt-1.5',
'[&>a:hover]:text-primary [&>a]:underline [&>a]:underline-offset-4',
className,
)}
{...props}
/>
)
}
function FieldSeparator({
children,
className,
...props
}: React.ComponentProps<'div'> & {
children?: React.ReactNode
}) {
return (
<div
data-slot="field-separator"
data-content={!!children}
className={cn(
'relative -my-2 h-5 text-sm group-data-[variant=outline]/field-group:-mb-2',
className,
)}
{...props}
>
<Separator className="absolute inset-0 top-1/2" />
{children && (
<span
className="bg-background text-muted-foreground relative mx-auto block w-fit px-2"
data-slot="field-separator-content"
>
{children}
</span>
)}
</div>
)
}
function FieldError({
className,
children,
errors,
...props
}: React.ComponentProps<'div'> & {
errors?: Array<{ message?: string } | undefined>
}) {
const content = useMemo(() => {
if (children) {
return children
}
if (!errors?.length) {
return null
}
const uniqueErrors = [
...new Map(errors.map((error) => [error?.message, error])).values(),
]
if (uniqueErrors?.length == 1) {
return uniqueErrors[0]?.message
}
return (
<ul className="ml-4 flex list-disc flex-col gap-1">
{uniqueErrors.map(
(error, index) =>
error?.message && <li key={index}>{error.message}</li>,
)}
</ul>
)
}, [children, errors])
if (!content) {
return null
}
return (
<div
role="alert"
data-slot="field-error"
className={cn('text-destructive text-sm font-normal', className)}
{...props}
>
{content}
</div>
)
}
export {
Field,
FieldLabel,
FieldDescription,
FieldError,
FieldGroup,
FieldLegend,
FieldSeparator,
FieldSet,
FieldContent,
FieldTitle,
}

View File

@ -0,0 +1,168 @@
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Textarea } from "@/components/ui/textarea"
function InputGroup({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="input-group"
role="group"
className={cn(
"group/input-group border-input dark:bg-input/30 relative flex w-full items-center rounded-md border shadow-xs transition-[color,box-shadow] outline-none",
"h-9 min-w-0 has-[>textarea]:h-auto",
// Variants based on alignment.
"has-[>[data-align=inline-start]]:[&>input]:pl-2",
"has-[>[data-align=inline-end]]:[&>input]:pr-2",
"has-[>[data-align=block-start]]:h-auto has-[>[data-align=block-start]]:flex-col has-[>[data-align=block-start]]:[&>input]:pb-3",
"has-[>[data-align=block-end]]:h-auto has-[>[data-align=block-end]]:flex-col has-[>[data-align=block-end]]:[&>input]:pt-3",
// Focus state.
"has-[[data-slot=input-group-control]:focus-visible]:border-ring has-[[data-slot=input-group-control]:focus-visible]:ring-ring/50 has-[[data-slot=input-group-control]:focus-visible]:ring-[3px]",
// Error state.
"has-[[data-slot][aria-invalid=true]]:ring-destructive/20 has-[[data-slot][aria-invalid=true]]:border-destructive dark:has-[[data-slot][aria-invalid=true]]:ring-destructive/40",
className
)}
{...props}
/>
)
}
const inputGroupAddonVariants = cva(
"text-muted-foreground flex h-auto cursor-text items-center justify-center gap-2 py-1.5 text-sm font-medium select-none [&>svg:not([class*='size-'])]:size-4 [&>kbd]:rounded-[calc(var(--radius)-5px)] group-data-[disabled=true]/input-group:opacity-50",
{
variants: {
align: {
"inline-start":
"order-first pl-3 has-[>button]:ml-[-0.45rem] has-[>kbd]:ml-[-0.35rem]",
"inline-end":
"order-last pr-3 has-[>button]:mr-[-0.45rem] has-[>kbd]:mr-[-0.35rem]",
"block-start":
"order-first w-full justify-start px-3 pt-3 [.border-b]:pb-3 group-has-[>input]/input-group:pt-2.5",
"block-end":
"order-last w-full justify-start px-3 pb-3 [.border-t]:pt-3 group-has-[>input]/input-group:pb-2.5",
},
},
defaultVariants: {
align: "inline-start",
},
}
)
function InputGroupAddon({
className,
align = "inline-start",
...props
}: React.ComponentProps<"div"> & VariantProps<typeof inputGroupAddonVariants>) {
return (
<div
role="group"
data-slot="input-group-addon"
data-align={align}
className={cn(inputGroupAddonVariants({ align }), className)}
onClick={(e) => {
if ((e.target as HTMLElement).closest("button")) {
return
}
e.currentTarget.parentElement?.querySelector("input")?.focus()
}}
{...props}
/>
)
}
const inputGroupButtonVariants = cva(
"text-sm shadow-none flex gap-2 items-center",
{
variants: {
size: {
xs: "h-6 gap-1 px-2 rounded-[calc(var(--radius)-5px)] [&>svg:not([class*='size-'])]:size-3.5 has-[>svg]:px-2",
sm: "h-8 px-2.5 gap-1.5 rounded-md has-[>svg]:px-2.5",
"icon-xs":
"size-6 rounded-[calc(var(--radius)-5px)] p-0 has-[>svg]:p-0",
"icon-sm": "size-8 p-0 has-[>svg]:p-0",
},
},
defaultVariants: {
size: "xs",
},
}
)
function InputGroupButton({
className,
type = "button",
variant = "ghost",
size = "xs",
...props
}: Omit<React.ComponentProps<typeof Button>, "size"> &
VariantProps<typeof inputGroupButtonVariants>) {
return (
<Button
type={type}
data-size={size}
variant={variant}
className={cn(inputGroupButtonVariants({ size }), className)}
{...props}
/>
)
}
function InputGroupText({ className, ...props }: React.ComponentProps<"span">) {
return (
<span
className={cn(
"text-muted-foreground flex items-center gap-2 text-sm [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
/>
)
}
function InputGroupInput({
className,
...props
}: React.ComponentProps<"input">) {
return (
<Input
data-slot="input-group-control"
className={cn(
"flex-1 rounded-none border-0 bg-transparent shadow-none focus-visible:ring-0 dark:bg-transparent",
className
)}
{...props}
/>
)
}
function InputGroupTextarea({
className,
...props
}: React.ComponentProps<"textarea">) {
return (
<Textarea
data-slot="input-group-control"
className={cn(
"flex-1 resize-none rounded-none border-0 bg-transparent py-3 shadow-none focus-visible:ring-0 dark:bg-transparent",
className
)}
{...props}
/>
)
}
export {
InputGroup,
InputGroupAddon,
InputGroupButton,
InputGroupText,
InputGroupInput,
InputGroupTextarea,
}

28
src/components/ui/kbd.tsx Normal file
View File

@ -0,0 +1,28 @@
import { cn } from "@/lib/utils"
function Kbd({ className, ...props }: React.ComponentProps<"kbd">) {
return (
<kbd
data-slot="kbd"
className={cn(
"bg-muted text-muted-foreground pointer-events-none inline-flex h-5 w-fit min-w-5 items-center justify-center gap-1 rounded-sm px-1 font-sans text-xs font-medium select-none",
"[&_svg:not([class*='size-'])]:size-3",
"[[data-slot=tooltip-content]_&]:bg-background/20 [[data-slot=tooltip-content]_&]:text-background dark:[[data-slot=tooltip-content]_&]:bg-background/10",
className
)}
{...props}
/>
)
}
function KbdGroup({ className, ...props }: React.ComponentProps<"div">) {
return (
<kbd
data-slot="kbd-group"
className={cn("inline-flex items-center gap-1", className)}
{...props}
/>
)
}
export { Kbd, KbdGroup }

View File

@ -0,0 +1,583 @@
'use client'
import {
DndContext,
DragOverlay,
KeyboardSensor,
MouseSensor,
TouchSensor,
closestCenter,
closestCorners,
defaultDropAnimationSideEffects,
useSensor,
useSensors,
} from '@dnd-kit/core'
import {
restrictToHorizontalAxis,
restrictToParentElement,
restrictToVerticalAxis,
} from '@dnd-kit/modifiers'
import {
SortableContext,
arrayMove,
horizontalListSortingStrategy,
sortableKeyboardCoordinates,
useSortable,
verticalListSortingStrategy,
} from '@dnd-kit/sortable'
import { CSS } from '@dnd-kit/utilities'
import { Slot } from '@radix-ui/react-slot'
import * as React from 'react'
import * as ReactDOM from 'react-dom'
import type { SortableContextProps } from '@dnd-kit/sortable'
import type {
Announcements,
DndContextProps,
DragEndEvent,
DragStartEvent,
DraggableAttributes,
DraggableSyntheticListeners,
DropAnimation,
ScreenReaderInstructions,
UniqueIdentifier,
} from '@dnd-kit/core'
import { useComposedRefs } from '@/lib/compose-refs'
import { cn } from '@/lib/utils'
const orientationConfig = {
vertical: {
modifiers: [restrictToVerticalAxis, restrictToParentElement],
strategy: verticalListSortingStrategy,
collisionDetection: closestCenter,
},
horizontal: {
modifiers: [restrictToHorizontalAxis, restrictToParentElement],
strategy: horizontalListSortingStrategy,
collisionDetection: closestCenter,
},
mixed: {
modifiers: [restrictToParentElement],
strategy: undefined,
collisionDetection: closestCorners,
},
}
const ROOT_NAME = 'Sortable'
const CONTENT_NAME = 'SortableContent'
const ITEM_NAME = 'SortableItem'
const ITEM_HANDLE_NAME = 'SortableItemHandle'
const OVERLAY_NAME = 'SortableOverlay'
interface SortableRootContextValue<T> {
id: string
items: Array<UniqueIdentifier>
modifiers: DndContextProps['modifiers']
strategy: SortableContextProps['strategy']
activeId: UniqueIdentifier | null
setActiveId: (id: UniqueIdentifier | null) => void
getItemValue: (item: T) => UniqueIdentifier
flatCursor: boolean
}
const SortableRootContext =
React.createContext<SortableRootContextValue<unknown> | null>(null)
function useSortableContext(consumerName: string) {
const context = React.useContext(SortableRootContext)
if (!context) {
throw new Error(`\`${consumerName}\` must be used within \`${ROOT_NAME}\``)
}
return context
}
interface GetItemValue<T> {
/**
* Callback that returns a unique identifier for each sortable item. Required for array of objects.
* @example getItemValue={(item) => item.id}
*/
getItemValue: (item: T) => UniqueIdentifier
}
type SortableRootProps<T> = DndContextProps &
(T extends object ? GetItemValue<T> : Partial<GetItemValue<T>>) & {
value: Array<T>
onValueChange?: (items: Array<T>) => void
onMove?: (
event: DragEndEvent & { activeIndex: number; overIndex: number },
) => void
strategy?: SortableContextProps['strategy']
orientation?: 'vertical' | 'horizontal' | 'mixed'
flatCursor?: boolean
}
function SortableRoot<T>(props: SortableRootProps<T>) {
const {
value,
onValueChange,
collisionDetection,
modifiers,
strategy,
onMove,
orientation = 'vertical',
flatCursor = false,
getItemValue: getItemValueProp,
accessibility,
onDragStart: onDragStartProp,
onDragEnd: onDragEndProp,
onDragCancel: onDragCancelProp,
...sortableProps
} = props
const id = React.useId()
const [activeId, setActiveId] = React.useState<UniqueIdentifier | null>(null)
const sensors = useSensors(
useSensor(MouseSensor),
useSensor(TouchSensor),
useSensor(KeyboardSensor, {
coordinateGetter: sortableKeyboardCoordinates,
}),
)
const config = React.useMemo(
() => orientationConfig[orientation],
[orientation],
)
const getItemValue = React.useCallback(
(item: T): UniqueIdentifier => {
if (typeof item === 'object' && !getItemValueProp) {
throw new Error('getItemValue is required when using array of objects')
}
return getItemValueProp
? getItemValueProp(item)
: (item as UniqueIdentifier)
},
[getItemValueProp],
)
const items = React.useMemo(() => {
return value.map((item) => getItemValue(item))
}, [value, getItemValue])
const onDragStart = React.useCallback(
(event: DragStartEvent) => {
onDragStartProp?.(event)
if (event.activatorEvent.defaultPrevented) return
setActiveId(event.active.id)
},
[onDragStartProp],
)
const onDragEnd = React.useCallback(
(event: DragEndEvent) => {
onDragEndProp?.(event)
if (event.activatorEvent.defaultPrevented) return
const { active, over } = event
if (over && active.id !== over?.id) {
const activeIndex = value.findIndex(
(item) => getItemValue(item) === active.id,
)
const overIndex = value.findIndex(
(item) => getItemValue(item) === over.id,
)
if (onMove) {
onMove({ ...event, activeIndex, overIndex })
} else {
onValueChange?.(arrayMove(value, activeIndex, overIndex))
}
}
setActiveId(null)
},
[value, onValueChange, onMove, getItemValue, onDragEndProp],
)
const onDragCancel = React.useCallback(
(event: DragEndEvent) => {
onDragCancelProp?.(event)
if (event.activatorEvent.defaultPrevented) return
setActiveId(null)
},
[onDragCancelProp],
)
const announcements: Announcements = React.useMemo(
() => ({
onDragStart({ active }) {
const activeValue = active.id.toString()
return `Grabbed sortable item "${activeValue}". Current position is ${active.data.current?.sortable.index + 1} of ${value.length}. Use arrow keys to move, space to drop.`
},
onDragOver({ active, over }) {
if (over) {
const overIndex = over.data.current?.sortable.index ?? 0
const activeIndex = active.data.current?.sortable.index ?? 0
const moveDirection = overIndex > activeIndex ? 'down' : 'up'
const activeValue = active.id.toString()
return `Sortable item "${activeValue}" moved ${moveDirection} to position ${overIndex + 1} of ${value.length}.`
}
return 'Sortable item is no longer over a droppable area. Press escape to cancel.'
},
onDragEnd({ active, over }) {
const activeValue = active.id.toString()
if (over) {
const overIndex = over.data.current?.sortable.index ?? 0
return `Sortable item "${activeValue}" dropped at position ${overIndex + 1} of ${value.length}.`
}
return `Sortable item "${activeValue}" dropped. No changes were made.`
},
onDragCancel({ active }) {
const activeIndex = active.data.current?.sortable.index ?? 0
const activeValue = active.id.toString()
return `Sorting cancelled. Sortable item "${activeValue}" returned to position ${activeIndex + 1} of ${value.length}.`
},
onDragMove({ active, over }) {
if (over) {
const overIndex = over.data.current?.sortable.index ?? 0
const activeIndex = active.data.current?.sortable.index ?? 0
const moveDirection = overIndex > activeIndex ? 'down' : 'up'
const activeValue = active.id.toString()
return `Sortable item "${activeValue}" is moving ${moveDirection} to position ${overIndex + 1} of ${value.length}.`
}
return 'Sortable item is no longer over a droppable area. Press escape to cancel.'
},
}),
[value],
)
const screenReaderInstructions: ScreenReaderInstructions = React.useMemo(
() => ({
draggable: `
To pick up a sortable item, press space or enter.
While dragging, use the ${orientation === 'vertical' ? 'up and down' : orientation === 'horizontal' ? 'left and right' : 'arrow'} keys to move the item.
Press space or enter again to drop the item in its new position, or press escape to cancel.
`,
}),
[orientation],
)
const contextValue = React.useMemo(
() => ({
id,
items,
modifiers: modifiers ?? config.modifiers,
strategy: strategy ?? config.strategy,
activeId,
setActiveId,
getItemValue,
flatCursor,
}),
[
id,
items,
modifiers,
strategy,
config.modifiers,
config.strategy,
activeId,
getItemValue,
flatCursor,
],
)
return (
<SortableRootContext.Provider
value={contextValue as SortableRootContextValue<unknown>}
>
<DndContext
collisionDetection={collisionDetection ?? config.collisionDetection}
modifiers={modifiers ?? config.modifiers}
sensors={sensors}
{...sortableProps}
id={id}
onDragStart={onDragStart}
onDragEnd={onDragEnd}
onDragCancel={onDragCancel}
accessibility={{
announcements,
screenReaderInstructions,
...accessibility,
}}
/>
</SortableRootContext.Provider>
)
}
const SortableContentContext = React.createContext<boolean>(false)
interface SortableContentProps extends React.ComponentProps<'div'> {
strategy?: SortableContextProps['strategy']
children: React.ReactNode
asChild?: boolean
withoutSlot?: boolean
}
function SortableContent(props: SortableContentProps) {
const {
strategy: strategyProp,
asChild,
withoutSlot,
children,
ref,
...contentProps
} = props
const context = useSortableContext(CONTENT_NAME)
const ContentPrimitive = asChild ? Slot : 'div'
return (
<SortableContentContext.Provider value={true}>
<SortableContext
items={context.items}
strategy={strategyProp ?? context.strategy}
>
{withoutSlot ? (
children
) : (
<ContentPrimitive
data-slot="sortable-content"
{...contentProps}
ref={ref}
>
{children}
</ContentPrimitive>
)}
</SortableContext>
</SortableContentContext.Provider>
)
}
interface SortableItemContextValue {
id: string
attributes: DraggableAttributes
listeners: DraggableSyntheticListeners | undefined
setActivatorNodeRef: (node: HTMLElement | null) => void
isDragging?: boolean
disabled?: boolean
}
const SortableItemContext =
React.createContext<SortableItemContextValue | null>(null)
function useSortableItemContext(consumerName: string) {
const context = React.useContext(SortableItemContext)
if (!context) {
throw new Error(`\`${consumerName}\` must be used within \`${ITEM_NAME}\``)
}
return context
}
interface SortableItemProps extends React.ComponentProps<'div'> {
value: UniqueIdentifier
asHandle?: boolean
asChild?: boolean
disabled?: boolean
}
function SortableItem(props: SortableItemProps) {
const {
value,
style,
asHandle,
asChild,
disabled,
className,
ref,
...itemProps
} = props
const inSortableContent = React.useContext(SortableContentContext)
const inSortableOverlay = React.useContext(SortableOverlayContext)
if (!inSortableContent && !inSortableOverlay) {
throw new Error(
`\`${ITEM_NAME}\` must be used within \`${CONTENT_NAME}\` or \`${OVERLAY_NAME}\``,
)
}
if (value === '') {
throw new Error(`\`${ITEM_NAME}\` value cannot be an empty string`)
}
const context = useSortableContext(ITEM_NAME)
const id = React.useId()
const {
attributes,
listeners,
setNodeRef,
setActivatorNodeRef,
transform,
transition,
isDragging,
} = useSortable({ id: value, disabled })
const composedRef = useComposedRefs(ref, (node) => {
if (disabled) return
setNodeRef(node)
if (asHandle) setActivatorNodeRef(node)
})
const composedStyle = React.useMemo<React.CSSProperties>(() => {
return {
transform: CSS.Translate.toString(transform),
transition,
...style,
}
}, [transform, transition, style])
const itemContext = React.useMemo<SortableItemContextValue>(
() => ({
id,
attributes,
listeners,
setActivatorNodeRef,
isDragging,
disabled,
}),
[id, attributes, listeners, setActivatorNodeRef, isDragging, disabled],
)
const ItemPrimitive = asChild ? Slot : 'div'
return (
<SortableItemContext.Provider value={itemContext}>
<ItemPrimitive
id={id}
data-disabled={disabled}
data-dragging={isDragging ? '' : undefined}
data-slot="sortable-item"
{...itemProps}
{...(asHandle && !disabled ? attributes : {})}
{...(asHandle && !disabled ? listeners : {})}
ref={composedRef}
style={composedStyle}
className={cn(
'focus-visible:outline-hidden focus-visible:ring-1 focus-visible:ring-ring focus-visible:ring-offset-1',
{
'touch-none select-none': asHandle,
'cursor-default': context.flatCursor,
'data-dragging:cursor-grabbing': !context.flatCursor,
'cursor-grab': !isDragging && asHandle && !context.flatCursor,
'opacity-50': isDragging,
'pointer-events-none opacity-50': disabled,
},
className,
)}
/>
</SortableItemContext.Provider>
)
}
interface SortableItemHandleProps extends React.ComponentProps<'button'> {
asChild?: boolean
}
function SortableItemHandle(props: SortableItemHandleProps) {
const { asChild, disabled, className, ref, ...itemHandleProps } = props
const context = useSortableContext(ITEM_HANDLE_NAME)
const itemContext = useSortableItemContext(ITEM_HANDLE_NAME)
const isDisabled = disabled ?? itemContext.disabled
const composedRef = useComposedRefs(ref, (node) => {
if (!isDisabled) return
itemContext.setActivatorNodeRef(node)
})
const HandlePrimitive = asChild ? Slot : 'button'
return (
<HandlePrimitive
type="button"
aria-controls={itemContext.id}
data-disabled={isDisabled}
data-dragging={itemContext.isDragging ? '' : undefined}
data-slot="sortable-item-handle"
{...itemHandleProps}
{...(isDisabled ? {} : itemContext.attributes)}
{...(isDisabled ? {} : itemContext.listeners)}
ref={composedRef}
className={cn(
'select-none disabled:pointer-events-none disabled:opacity-50',
context.flatCursor
? 'cursor-default'
: 'cursor-grab data-dragging:cursor-grabbing',
className,
)}
disabled={isDisabled}
/>
)
}
const SortableOverlayContext = React.createContext(false)
const dropAnimation: DropAnimation = {
sideEffects: defaultDropAnimationSideEffects({
styles: {
active: {
opacity: '0.4',
},
},
}),
}
interface SortableOverlayProps
extends Omit<React.ComponentProps<typeof DragOverlay>, 'children'> {
container?: Element | DocumentFragment | null
children?:
| ((params: { value: UniqueIdentifier }) => React.ReactNode)
| React.ReactNode
}
function SortableOverlay(props: SortableOverlayProps) {
const { container: containerProp, children, ...overlayProps } = props
const context = useSortableContext(OVERLAY_NAME)
const [mounted, setMounted] = React.useState(false)
React.useLayoutEffect(() => setMounted(true), [])
const container =
containerProp ?? (mounted ? globalThis.document?.body : null)
if (!container) return null
return ReactDOM.createPortal(
<DragOverlay
dropAnimation={dropAnimation}
modifiers={context.modifiers}
className={cn(!context.flatCursor && 'cursor-grabbing')}
{...overlayProps}
>
<SortableOverlayContext.Provider value={true}>
{context.activeId
? typeof children === 'function'
? children({ value: context.activeId })
: children
: null}
</SortableOverlayContext.Provider>
</DragOverlay>,
container,
)
}
export {
SortableRoot as Sortable,
SortableContent,
SortableItem,
SortableItemHandle,
SortableOverlay,
//
SortableRoot as Root,
SortableContent as Content,
SortableItem as Item,
SortableItemHandle as ItemHandle,
SortableOverlay as Overlay,
}

114
src/components/ui/table.tsx Normal file
View File

@ -0,0 +1,114 @@
import * as React from "react"
import { cn } from "@/lib/utils"
function Table({ className, ...props }: React.ComponentProps<"table">) {
return (
<div
data-slot="table-container"
className="relative w-full overflow-x-auto"
>
<table
data-slot="table"
className={cn("w-full caption-bottom text-sm", className)}
{...props}
/>
</div>
)
}
function TableHeader({ className, ...props }: React.ComponentProps<"thead">) {
return (
<thead
data-slot="table-header"
className={cn("[&_tr]:border-b", className)}
{...props}
/>
)
}
function TableBody({ className, ...props }: React.ComponentProps<"tbody">) {
return (
<tbody
data-slot="table-body"
className={cn("[&_tr:last-child]:border-0", className)}
{...props}
/>
)
}
function TableFooter({ className, ...props }: React.ComponentProps<"tfoot">) {
return (
<tfoot
data-slot="table-footer"
className={cn(
"bg-muted/50 border-t font-medium [&>tr]:last:border-b-0",
className
)}
{...props}
/>
)
}
function TableRow({ className, ...props }: React.ComponentProps<"tr">) {
return (
<tr
data-slot="table-row"
className={cn(
"hover:bg-muted/50 data-[state=selected]:bg-muted border-b transition-colors",
className
)}
{...props}
/>
)
}
function TableHead({ className, ...props }: React.ComponentProps<"th">) {
return (
<th
data-slot="table-head"
className={cn(
"text-foreground h-10 px-2 text-left align-middle font-medium whitespace-nowrap [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
className
)}
{...props}
/>
)
}
function TableCell({ className, ...props }: React.ComponentProps<"td">) {
return (
<td
data-slot="table-cell"
className={cn(
"p-2 align-middle whitespace-nowrap [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
className
)}
{...props}
/>
)
}
function TableCaption({
className,
...props
}: React.ComponentProps<"caption">) {
return (
<caption
data-slot="table-caption"
className={cn("text-muted-foreground mt-4 text-sm", className)}
{...props}
/>
)
}
export {
Table,
TableHeader,
TableBody,
TableFooter,
TableHead,
TableRow,
TableCell,
TableCaption,
}

View File

@ -0,0 +1,64 @@
import * as React from 'react'
import * as TabsPrimitive from '@radix-ui/react-tabs'
import { cn } from '@/lib/utils'
function Tabs({
className,
...props
}: React.ComponentProps<typeof TabsPrimitive.Root>) {
return (
<TabsPrimitive.Root
data-slot="tabs"
className={cn('flex flex-col gap-2', className)}
{...props}
/>
)
}
function TabsList({
className,
...props
}: React.ComponentProps<typeof TabsPrimitive.List>) {
return (
<TabsPrimitive.List
data-slot="tabs-list"
className={cn(
'bg-muted text-muted-foreground inline-flex h-9 w-fit items-center justify-center rounded-lg p-[3px]',
className,
)}
{...props}
/>
)
}
function TabsTrigger({
className,
...props
}: React.ComponentProps<typeof TabsPrimitive.Trigger>) {
return (
<TabsPrimitive.Trigger
data-slot="tabs-trigger"
className={cn(
"data-[state=active]:bg-background dark:data-[state=active]:text-foreground focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:outline-ring dark:data-[state=active]:border-input dark:data-[state=active]:bg-input/30 text-foreground dark:text-muted-foreground inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 rounded-md border border-transparent px-2 py-1 text-sm font-medium whitespace-nowrap transition-[color,box-shadow] focus-visible:ring-[3px] focus-visible:outline-1 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:shadow-sm [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className,
)}
{...props}
/>
)
}
function TabsContent({
className,
...props
}: React.ComponentProps<typeof TabsPrimitive.Content>) {
return (
<TabsPrimitive.Content
data-slot="tabs-content"
className={cn('flex-1 outline-none', className)}
{...props}
/>
)
}
export { Tabs, TabsList, TabsTrigger, TabsContent }

View File

@ -0,0 +1,73 @@
"use client"
import * as React from "react"
import * as ToggleGroupPrimitive from "@radix-ui/react-toggle-group"
import { type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
import { toggleVariants } from "@/components/ui/toggle"
const ToggleGroupContext = React.createContext<
VariantProps<typeof toggleVariants>
>({
size: "default",
variant: "default",
})
function ToggleGroup({
className,
variant,
size,
children,
...props
}: React.ComponentProps<typeof ToggleGroupPrimitive.Root> &
VariantProps<typeof toggleVariants>) {
return (
<ToggleGroupPrimitive.Root
data-slot="toggle-group"
data-variant={variant}
data-size={size}
className={cn(
"group/toggle-group flex w-fit items-center rounded-md data-[variant=outline]:shadow-xs",
className
)}
{...props}
>
<ToggleGroupContext.Provider value={{ variant, size }}>
{children}
</ToggleGroupContext.Provider>
</ToggleGroupPrimitive.Root>
)
}
function ToggleGroupItem({
className,
children,
variant,
size,
...props
}: React.ComponentProps<typeof ToggleGroupPrimitive.Item> &
VariantProps<typeof toggleVariants>) {
const context = React.useContext(ToggleGroupContext)
return (
<ToggleGroupPrimitive.Item
data-slot="toggle-group-item"
data-variant={context.variant || variant}
data-size={context.size || size}
className={cn(
toggleVariants({
variant: context.variant || variant,
size: context.size || size,
}),
"min-w-0 flex-1 shrink-0 rounded-none shadow-none first:rounded-l-md last:rounded-r-md focus:z-10 focus-visible:z-10 data-[variant=outline]:border-l-0 data-[variant=outline]:first:border-l",
className
)}
{...props}
>
{children}
</ToggleGroupPrimitive.Item>
)
}
export { ToggleGroup, ToggleGroupItem }

View File

@ -0,0 +1,45 @@
import * as React from "react"
import * as TogglePrimitive from "@radix-ui/react-toggle"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const toggleVariants = cva(
"inline-flex items-center justify-center gap-2 rounded-md text-sm font-medium hover:bg-muted hover:text-muted-foreground disabled:pointer-events-none disabled:opacity-50 data-[state=on]:bg-accent data-[state=on]:text-accent-foreground [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 [&_svg]:shrink-0 focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] outline-none transition-[color,box-shadow] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive whitespace-nowrap",
{
variants: {
variant: {
default: "bg-transparent",
outline:
"border border-input bg-transparent shadow-xs hover:bg-accent hover:text-accent-foreground",
},
size: {
default: "h-9 px-2 min-w-9",
sm: "h-8 px-1.5 min-w-8",
lg: "h-10 px-2.5 min-w-10",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
function Toggle({
className,
variant,
size,
...props
}: React.ComponentProps<typeof TogglePrimitive.Root> &
VariantProps<typeof toggleVariants>) {
return (
<TogglePrimitive.Root
data-slot="toggle"
className={cn(toggleVariants({ variant, size, className }))}
{...props}
/>
)
}
export { Toggle, toggleVariants }

View File

@ -1,50 +0,0 @@
import { faker } from '@faker-js/faker'
export type Person = {
id: number
firstName: string
lastName: string
age: number
visits: number
progress: number
status: 'relationship' | 'complicated' | 'single'
subRows?: Person[]
}
const range = (len: number) => {
const arr: number[] = []
for (let i = 0; i < len; i++) {
arr.push(i)
}
return arr
}
const newPerson = (num: number): Person => {
return {
id: num,
firstName: faker.person.firstName(),
lastName: faker.person.lastName(),
age: faker.number.int(40),
visits: faker.number.int(1000),
progress: faker.number.int(100),
status: faker.helpers.shuffle<Person['status']>([
'relationship',
'complicated',
'single',
])[0]!,
}
}
export function makeData(...lens: number[]) {
const makeDataLevel = (depth = 0): Person[] => {
const len = lens[depth]!
return range(len).map((index): Person => {
return {
...newPerson(index),
subRows: lens[depth + 1] ? makeDataLevel(depth + 1) : undefined,
}
})
}
return makeDataLevel()
}

View File

@ -14,6 +14,7 @@ export const env = createEnv({
client: {
VITE_APP_TITLE: z.string().min(1).optional(),
VITE_BACKEND_URI: z.string(),
},
/**

View File

@ -3,6 +3,7 @@
import { BadgeCheck, Bell, ChevronsUpDown, LogOut } from 'lucide-react'
import { Link } from '@tanstack/react-router'
import { useProfile } from '../queries'
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
import {
DropdownMenu,
@ -19,6 +20,7 @@ import {
SidebarMenuItem,
useSidebar,
} from '@/components/ui/sidebar'
import { env } from '@/env'
export function NavUser({
user,
@ -30,6 +32,11 @@ export function NavUser({
}
}) {
const { isMobile } = useSidebar()
const { data } = useProfile()
if (!data) {
return <div>Loading...</div>
}
return (
<SidebarMenu>
@ -41,12 +48,12 @@ export function NavUser({
className="data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground"
>
<Avatar className="h-8 w-8 rounded-lg">
<AvatarImage src={user.avatar} alt={user.name} />
<AvatarFallback className="rounded-lg">CN</AvatarFallback>
<AvatarImage src={data.picture} alt={data.name} />
<AvatarFallback className="rounded-lg">KH</AvatarFallback>
</Avatar>
<div className="grid flex-1 text-left text-sm leading-tight">
<span className="truncate font-medium">{user.name}</span>
<span className="truncate text-xs">{user.email}</span>
<span className="truncate font-medium">{data.name}</span>
<span className="truncate text-xs">{data.Email}</span>
</div>
<ChevronsUpDown className="ml-auto size-4" />
</SidebarMenuButton>
@ -60,30 +67,26 @@ export function NavUser({
<DropdownMenuLabel className="p-0 font-normal">
<div className="flex items-center gap-2 px-1 py-1.5 text-left text-sm">
<Avatar className="h-8 w-8 rounded-lg">
<AvatarImage src={user.avatar} alt={user.name} />
<AvatarImage src={data.picture} alt={data.name} />
<AvatarFallback className="rounded-lg">CN</AvatarFallback>
</Avatar>
<div className="grid flex-1 text-left text-sm leading-tight">
<span className="truncate font-medium">{user.name}</span>
<span className="truncate text-xs">{user.email}</span>
<span className="truncate font-medium">{data.name}</span>
<span className="truncate text-xs">{data.Email}</span>
</div>
</div>
</DropdownMenuLabel>
<DropdownMenuSeparator />
{/* <DropdownMenuGroup>
<DropdownMenuItem>
<Sparkles />
Upgrade to Pro
</DropdownMenuItem>
</DropdownMenuGroup>
<DropdownMenuSeparator /> */}
<DropdownMenuGroup>
<Link to="/about">
<a
href="https://keycloak.kocoder.xyz/realms/che/account"
target="_blank"
>
<DropdownMenuItem>
<BadgeCheck />
Account
</DropdownMenuItem>
</Link>
</a>
{/* <DropdownMenuItem>
<CreditCard />
Billing
@ -96,7 +99,7 @@ export function NavUser({
</Link>
</DropdownMenuGroup>
<DropdownMenuSeparator />
<a href="http://localhost:3000/api/logout">
<a href={`${env.VITE_BACKEND_URI}/api/auth/logout`}>
<DropdownMenuItem>
<LogOut />
Log out

View File

@ -1,4 +1,6 @@
import { useQuery } from '@tanstack/react-query'
import { useNavigate } from '@tanstack/react-router'
import { useQuery, useQueryClient } from '@tanstack/react-query'
import { env } from '@/env'
const sessionKeys = {
all: ['sessions'] as const,
@ -11,12 +13,23 @@ export type Session = {
CreatedAt: Date
}
export type User = {
ID: number
CreatedAt: Date
UpdatedAt: Date
DeletedAt: Date | undefined
sub: string
Email: string
name: string
picture: string
}
export function useCurrentSession() {
return useQuery<Session>({
queryKey: sessionKeys.current(),
queryFn: async () => {
const data = await fetch(
'http://localhost:3000/api/auth/currentSession',
env.VITE_BACKEND_URI + '/api/auth/currentSession',
{
credentials: 'include',
},
@ -25,3 +38,22 @@ export function useCurrentSession() {
},
})
}
export function useProfile() {
const queryClient = useQueryClient()
const navigate = useNavigate()
return useQuery<User>({
queryKey: sessionKeys.current(),
queryFn: async () => {
const data = await fetch(env.VITE_BACKEND_URI + '/v1/users/current', {
credentials: 'include',
})
if (data.status == 401) {
queryClient.invalidateQueries()
navigate({ to: '/' })
}
return await data.json()
},
})
}

View File

@ -4,6 +4,7 @@ import {
useQueryClient,
useSuspenseQuery,
} from '@tanstack/react-query'
import { env } from '@/env'
const ansprechpartnerKeys = {
all: ['ansprechpartner'] as const,
@ -35,9 +36,12 @@ export function useAllAnsprechpartners() {
return useQuery<Ansprechpartner>({
queryKey: ansprechpartnerKeys.lists(),
queryFn: async () => {
const data = await fetch('http://localhost:3000/v1/ansprechpartner/all', {
const data = await fetch(
env.VITE_BACKEND_URI + '/v1/ansprechpartner/all',
{
credentials: 'include',
})
},
)
return await data.json()
},
})
@ -48,7 +52,7 @@ export function useAnsprechpartner(id: number) {
queryKey: ansprechpartnerKeys.detail(id),
queryFn: async () => {
const data = await fetch(
'http://localhost:3000/v1/ansprechpartner/' + id,
env.VITE_BACKEND_URI + '/v1/ansprechpartner/' + id,
{ credentials: 'include' },
)
return await data.json()
@ -62,7 +66,7 @@ export function useAnsprechpartnerEditMutation() {
return useMutation({
mutationFn: async (ansprechpartner: Ansprechpartner) => {
await fetch(
'http://localhost:3000/v1/ansprechpartner/' + ansprechpartner.ID,
env.VITE_BACKEND_URI + '/v1/ansprechpartner/' + ansprechpartner.ID,
{
headers: {
'content-type': 'application/json',

View File

@ -0,0 +1,40 @@
import { ToggleGroup, ToggleGroupItem } from '@/components/ui/toggle-group'
import { EditorContent, useEditor } from '@tiptap/react'
import { BubbleMenu, FloatingMenu } from '@tiptap/react/menus'
import StarterKit from '@tiptap/starter-kit'
import { Bold, Italic, Underline } from 'lucide-react'
const Tiptap = () => {
const editor = useEditor({
extensions: [StarterKit], // define your extension array
content: '<p>Hello World!</p>', // initial content
immediatelyRender: false,
})
if (!editor) return <></>
return (
<>
<EditorContent editor={editor} />
<FloatingMenu editor={editor}>This is the floating menu</FloatingMenu>
<BubbleMenu editor={editor}>
<ToggleGroup variant="outline" type="multiple">
<ToggleGroupItem value="bold" aria-label="Toggle bold">
<Bold className="h-4 w-4" />
</ToggleGroupItem>
<ToggleGroupItem value="italic" aria-label="Toggle italic">
<Italic className="h-4 w-4" />
</ToggleGroupItem>
<ToggleGroupItem
value="strikethrough"
aria-label="Toggle strikethrough"
>
<Underline className="h-4 w-4" />
</ToggleGroupItem>
</ToggleGroup>
</BubbleMenu>
</>
)
}
export default Tiptap

View File

@ -0,0 +1,11 @@
import type { ReactNode } from 'react'
function Kanban({ children }: { children: ReactNode }) {
return (
<div className="box-border flex gap-2 h-full min-w-full max-w-screen overflow-x-auto snap-x snap-mandatory md:snap-none">
{children}
</div>
)
}
export default Kanban

View File

@ -0,0 +1,15 @@
type Card = {
name: string
path: string
description: string
labels: Array<{
name: string
className: string
}>
}
function KanbanCard({ card }: { card: Card }) {
return <div className="bg-background rounded p-2">{card.name}</div>
}
export default KanbanCard

View File

@ -0,0 +1,59 @@
import { GripVertical, Tickets } from 'lucide-react'
import { useRef } from 'react'
import type { ReactNode } from 'react'
function KanbanColumn({
children,
name,
itemCount,
}: {
children: ReactNode
name: string
itemCount: number
}) {
const column = useRef<HTMLDivElement>(null)
const handleDraggableStart = () => {
column.current?.setAttribute('draggable', 'true')
}
const handleDraggableStop = () => {
column.current?.setAttribute('draggable', 'false')
}
const handleDragStart = (e: DragEvent) => {
e.dataTransfer?.setData('text/custom', 'ASDF')
}
const handleDragStop = () => {
column.current?.setAttribute('draggable', 'false')
}
return (
<div
className="min-w-96 bg-accent rounded-md p-4 snap-center"
ref={column}
onDragStart={handleDragStart}
onDragEnd={handleDragStop}
>
<div className="flex justify-between mb-2">
<div className="flex place-items-center">
<GripVertical
size={20}
className="mr-2 cursor-grab"
onMouseDown={handleDraggableStart}
onMouseUp={handleDraggableStop}
/>
<p>{name}</p>
</div>
<div className="flex place-items-center gap-2">
{itemCount}
<Tickets size={20} />
</div>
</div>
{children}
</div>
)
}
export default KanbanColumn

View File

@ -0,0 +1,28 @@
import { useRef } from 'react'
function KanbanDropzone() {
const ref = useRef<HTMLDivElement>(null)
const handleDragEnter = () => {
console.log('DRAGOVER')
if (!ref.current) return
ref.current.style.backgroundColor = '#41b2b2'
}
const handleDragLeave = () => {
console.log('DRAGOVER')
if (!ref.current) return
ref.current.style.backgroundColor = 'transparent'
}
return (
<div
className="min-w-1 h-full block"
ref={ref}
onDragEnter={handleDragEnter}
onDragLeave={handleDragLeave}
></div>
)
}
export default KanbanDropzone

View File

@ -1,4 +1,5 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { env } from '@/env'
const mandantKeys = {
all: ['mandant'] as const,
@ -18,7 +19,7 @@ export function useCurrentMandant() {
return useQuery<Mandant>({
queryKey: mandantKeys.current(),
queryFn: async () => {
const data = await fetch('http://localhost:3000/v1/mandant/current', {
const data = await fetch(env.VITE_BACKEND_URI + '/v1/mandant/current', {
credentials: 'include',
})
return await data.json()
@ -30,7 +31,7 @@ export function useAllMandanten() {
return useQuery<Array<Mandant>>({
queryKey: mandantKeys.lists(),
queryFn: async () => {
const data = await fetch('http://localhost:3000/v1/mandant/all', {
const data = await fetch(env.VITE_BACKEND_URI + '/v1/mandant/all', {
credentials: 'include',
})
return await data.json()
@ -43,7 +44,7 @@ export function useCurrentMandantMutation() {
return useMutation({
mutationFn: async (mandant: Mandant) => {
const res = await fetch('http://localhost:3000/v1/mandant/current', {
const res = await fetch(env.VITE_BACKEND_URI + '/v1/mandant/current', {
headers: {
'content-type': 'application/json',
},

View File

@ -1,18 +1,13 @@
import {
Folder,
MoreHorizontal,
Share,
Trash2,
type LucideIcon,
} from "lucide-react"
import { Folder, MoreHorizontal, Share, Trash2 } from 'lucide-react'
import { Link } from '@tanstack/react-router'
import { useAllProjects } from '../queries'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"
} from '@/components/ui/dropdown-menu'
import {
SidebarGroup,
SidebarGroupLabel,
@ -21,30 +16,30 @@ import {
SidebarMenuButton,
SidebarMenuItem,
useSidebar,
} from "@/components/ui/sidebar"
} from '@/components/ui/sidebar'
export function NavProjects({
projects,
}: {
projects: {
name: string
url: string
icon: LucideIcon
}[]
}) {
export function NavProjects() {
const { isMobile } = useSidebar()
const { data: projects } = useAllProjects({
fetchSize: 5,
sorting: [],
})
if (!projects) {
return <p>Loading...</p>
}
return (
<SidebarGroup className="group-data-[collapsible=icon]:hidden">
<SidebarGroupLabel>Projects</SidebarGroupLabel>
<SidebarMenu>
{projects.map((item) => (
{projects.pages[0].data.map((item) => (
<SidebarMenuItem key={item.name}>
<SidebarMenuButton asChild>
<a href={item.url}>
<item.icon />
<Link to="/projects/view/$id" params={{ id: item.ID.toString() }}>
{item.icon}
<span>{item.name}</span>
</a>
</Link>
</SidebarMenuButton>
<DropdownMenu>
<DropdownMenuTrigger asChild>
@ -55,8 +50,8 @@ export function NavProjects({
</DropdownMenuTrigger>
<DropdownMenuContent
className="w-48"
side={isMobile ? "bottom" : "right"}
align={isMobile ? "end" : "start"}
side={isMobile ? 'bottom' : 'right'}
align={isMobile ? 'end' : 'start'}
>
<DropdownMenuItem>
<Folder className="text-muted-foreground" />

View File

@ -0,0 +1,196 @@
import {
CircleCheckIcon,
CircleUserIcon,
ClipboardCheckIcon,
MoreHorizontalIcon,
ReceiptEuroIcon,
SpeakerIcon,
} from 'lucide-react'
import { useState } from 'react'
import { Link } from '@tanstack/react-router'
import { useAllProjects } from '../queries'
import type {
ColumnDef,
RowSelectionState,
SortingState,
VisibilityState,
} from '@tanstack/react-table'
import type { PaginatedProject } from '../queries'
import { Checkbox } from '@/components/ui/checkbox'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'
import { Button } from '@/components/ui/button'
import { DataTableColumnHeader } from '@/components/data-table-column-header'
import { DataTable } from '@/components/data-table'
const iconSize = 16
export const columnDefs: Array<ColumnDef<PaginatedProject>> = [
{
id: 'select',
header: ({ table }) => (
<Checkbox
checked={
table.getIsAllPageRowsSelected() ||
(table.getIsSomePageRowsSelected() && 'indeterminate')
}
onCheckedChange={(value) => table.toggleAllPageRowsSelected(!!value)}
aria-label="Select all"
className="aspect-square"
/>
),
cell: ({ row }) => (
<Checkbox
checked={row.getIsSelected()}
onCheckedChange={(value) => row.toggleSelected(!!value)}
aria-label="Select row"
/>
),
enableSorting: false,
enableHiding: false,
size: 26,
},
{
accessorKey: 'name',
header: ({ column, header }) => {
return (
<DataTableColumnHeader
column={column}
title="Name"
style={{ width: header.getSize() }}
/>
)
},
size: 200,
},
{
accessorKey: 'description',
header: ({ column }) => {
return <DataTableColumnHeader column={column} title="Beschreibung" />
},
size: 400,
},
{
accessorKey: 'progress',
header: 'Project progress',
cell: () => {
return (
<div className="flex flex-row gap-2 items-center h-4">
<SpeakerIcon size={iconSize} />
<CircleUserIcon size={iconSize} />
<CircleCheckIcon size={iconSize} />
<ReceiptEuroIcon size={iconSize} />
<ClipboardCheckIcon size={iconSize} />
</div>
)
},
size: 200,
},
{
accessorKey: 'client',
header: 'Kunde',
size: 200,
},
{
accessorKey: 'location',
header: 'Location',
size: 200,
},
{
accessorKey: 'startdate',
header: 'Planungszeitraum Start',
size: 200,
},
{
accessorKey: 'enddate',
header: 'Planungszeitraum Ende',
size: 200,
},
{
accessorKey: 'type',
header: 'Projekttyp',
size: 200,
},
{
id: 'actions',
cell: ({ row }) => {
const project = row.original
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" className="h-8 w-8 p-0">
<span className="sr-only">Open menu</span>
<MoreHorizontalIcon className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuLabel>Actions</DropdownMenuLabel>
<DropdownMenuItem
onClick={() =>
navigator.clipboard.writeText(project.ID.toString())
}
>
Copy project ID
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem asChild>
<Link
to="/projects/view/$id"
params={{ id: project.ID.toString() }}
>
View Project
</Link>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)
},
size: 50,
},
]
const fetchSize = 25
function ProjectsTable() {
const [sorting, setSorting] = useState<SortingState>([])
const [columnVisibility, setColumnVisibility] = useState<VisibilityState>({
description: false,
client: false,
location: false,
type: false,
enddate: false,
startdate: false,
})
const [selected, setSelected] = useState<RowSelectionState>({})
const { data, fetchNextPage, isFetching, isLoading } = useAllProjects({
fetchSize,
sorting,
})
return (
<DataTable
columns={columnDefs}
data={data}
columnVisibility={columnVisibility}
setColumnVisibility={setColumnVisibility}
fetchNextPage={fetchNextPage}
isFetching={isFetching}
isLoading={isLoading}
setSorting={setSorting}
sorting={sorting}
rowSelection={selected}
setRowSelection={setSelected}
/>
)
}
export default ProjectsTable

View File

@ -0,0 +1,119 @@
import {
keepPreviousData,
useInfiniteQuery,
useMutation,
useQuery,
useQueryClient,
} from '@tanstack/react-query'
import { env } from '@/env'
const projectKeys = {
all: ['projects'] as const,
lists: () => [...projectKeys.all, 'list'] as const,
getAll: (fetchSize: number, sorting: any) =>
[...projectKeys.lists(), 'all', fetchSize, sorting] as const,
get: (id: number) => [...projectKeys.all, 'get', id] as const,
}
export type PaginatedProject = {
data: Array<Project>
meta: {
totalProjectsCount: number
}
}
export type Project = {
ID: number
name: string
description: string
icon: string
MandantID: number
}
export function getProjectQueryObject(id: number) {
return {
queryKey: projectKeys.get(id),
queryFn: async () => {
const data = await fetch(env.VITE_BACKEND_URI + '/v1/projects/' + id, {
credentials: 'include',
})
return await data.json()
},
}
}
export function useProject(id: number) {
return useQuery<Project>(getProjectQueryObject(id))
}
export function useAllProjects({
fetchSize,
sorting,
}: {
fetchSize: number
sorting: any
}) {
return useInfiniteQuery<PaginatedProject>({
queryKey: projectKeys.getAll(fetchSize, sorting),
queryFn: async ({ pageParam = 0 }) => {
const start = (pageParam as number) * fetchSize
const data = await fetch(
env.VITE_BACKEND_URI +
'/v1/projects/all?' +
new URLSearchParams(sorting[0]),
{
credentials: 'include',
headers: {
'X-OFFSET': start.toString(),
'X-PER-PAGE': fetchSize.toString(),
},
},
)
return await data.json()
},
initialPageParam: 0,
getNextPageParam: (_lastGroup, groups) => groups.length,
refetchOnWindowFocus: false,
placeholderData: keepPreviousData,
})
}
export function useProjectEdit(id: number) {
return useMutation({
mutationFn: async (project: Project) => {
await fetch(env.VITE_BACKEND_URI + '/v1/projects/' + id + '/edit', {
headers: {
'content-type': 'application/json',
},
method: 'POST',
body: JSON.stringify(project),
credentials: 'include',
})
},
})
}
export function useProjectCreate() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: async (project: Project) => {
const res = await fetch(env.VITE_BACKEND_URI + '/v1/projects/new', {
headers: {
'content-type': 'application/json',
},
method: 'POST',
body: JSON.stringify(project),
credentials: 'include',
})
const newCurrentMandant = await res.json()
queryClient.invalidateQueries({ queryKey: projectKeys.lists() })
queryClient.setQueryData(
projectKeys.get(newCurrentMandant.id),
(_: Project) => newCurrentMandant,
)
},
})
}

62
src/lib/compose-refs.ts Normal file
View File

@ -0,0 +1,62 @@
import * as React from "react";
type PossibleRef<T> = React.Ref<T> | undefined;
/**
* Set a given ref to a given value
* This utility takes care of different types of refs: callback refs and RefObject(s)
*/
function setRef<T>(ref: PossibleRef<T>, value: T) {
if (typeof ref === "function") {
return ref(value);
}
if (ref !== null && ref !== undefined) {
ref.current = value;
}
}
/**
* A utility to compose multiple refs together
* Accepts callback refs and RefObject(s)
*/
function composeRefs<T>(...refs: PossibleRef<T>[]): React.RefCallback<T> {
return (node) => {
let hasCleanup = false;
const cleanups = refs.map((ref) => {
const cleanup = setRef(ref, node);
if (!hasCleanup && typeof cleanup === "function") {
hasCleanup = true;
}
return cleanup;
});
// React <19 will log an error to the console if a callback ref returns a
// value. We don't use ref cleanups internally so this will only happen if a
// user's ref callback returns a value, which we only expect if they are
// using the cleanup functionality added in React 19.
if (hasCleanup) {
return () => {
for (let i = 0; i < cleanups.length; i++) {
const cleanup = cleanups[i];
if (typeof cleanup === "function") {
cleanup();
} else {
setRef(refs[i], null);
}
}
};
}
};
}
/**
* A custom hook that composes multiple refs
* Accepts callback refs and RefObject(s)
*/
function useComposedRefs<T>(...refs: PossibleRef<T>[]): React.RefCallback<T> {
// biome-ignore lint/correctness/useExhaustiveDependencies: we don't want to re-run this callback when the refs change
return React.useCallback(composeRefs(...refs), refs);
}
export { composeRefs, useComposedRefs };

17
src/lib/format.ts Normal file
View File

@ -0,0 +1,17 @@
export function formatDate(
date: Date | string | number | undefined,
opts: Intl.DateTimeFormatOptions = {},
) {
if (!date) return "";
try {
return new Intl.DateTimeFormat("en-US", {
month: opts.month ?? "long",
day: opts.day ?? "numeric",
year: opts.year ?? "numeric",
...opts,
}).format(new Date(date));
} catch (_err) {
return "";
}
}

99
src/lib/parsers.ts Normal file
View File

@ -0,0 +1,99 @@
import { createParser } from "nuqs/server";
import { z } from "zod";
import { dataTableConfig } from "@/components/data-table/data-table";
import type {
ExtendedColumnFilter,
ExtendedColumnSort,
} from "@/components/data-table/data-table";
const sortingItemSchema = z.object({
id: z.string(),
desc: z.boolean(),
});
export const getSortingStateParser = <TData>(
columnIds?: string[] | Set<string>,
) => {
const validKeys = columnIds
? columnIds instanceof Set
? columnIds
: new Set(columnIds)
: null;
return createParser({
parse: (value) => {
try {
const parsed = JSON.parse(value);
const result = z.array(sortingItemSchema).safeParse(parsed);
if (!result.success) return null;
if (validKeys && result.data.some((item) => !validKeys.has(item.id))) {
return null;
}
return result.data as ExtendedColumnSort<TData>[];
} catch {
return null;
}
},
serialize: (value) => JSON.stringify(value),
eq: (a, b) =>
a.length === b.length &&
a.every(
(item, index) =>
item.id === b[index]?.id && item.desc === b[index]?.desc,
),
});
};
const filterItemSchema = z.object({
id: z.string(),
value: z.union([z.string(), z.array(z.string())]),
variant: z.enum(dataTableConfig.filterVariants),
operator: z.enum(dataTableConfig.operators),
filterId: z.string(),
});
export type FilterItemSchema = z.infer<typeof filterItemSchema>;
export const getFiltersStateParser = <TData>(
columnIds?: string[] | Set<string>,
) => {
const validKeys = columnIds
? columnIds instanceof Set
? columnIds
: new Set(columnIds)
: null;
return createParser({
parse: (value) => {
try {
const parsed = JSON.parse(value);
const result = z.array(filterItemSchema).safeParse(parsed);
if (!result.success) return null;
if (validKeys && result.data.some((item) => !validKeys.has(item.id))) {
return null;
}
return result.data as ExtendedColumnFilter<TData>[];
} catch {
return null;
}
},
serialize: (value) => JSON.stringify(value),
eq: (a, b) =>
a.length === b.length &&
a.every(
(filter, index) =>
filter.id === b[index]?.id &&
filter.value === b[index]?.value &&
filter.variant === b[index]?.variant &&
filter.operator === b[index]?.operator,
),
});
};

View File

@ -1,6 +1,45 @@
import { clsx, type ClassValue } from 'clsx'
import { clsx } from 'clsx'
import { twMerge } from 'tailwind-merge'
import { useEffect } from 'react'
import { useQueryClient } from '@tanstack/react-query'
import type { ClassValue } from 'clsx'
import { env } from '@/env'
export function cn(...inputs: ClassValue[]) {
export function cn(...inputs: Array<ClassValue>) {
return twMerge(clsx(inputs))
}
export const useReactQuerySubscription = () => {
const queryClient = useQueryClient()
useEffect(() => {
let interval: any
const websocket = new WebSocket(env.VITE_BACKEND_URI + '/ws')
websocket.onopen = () => {
console.log('[Message Bus] opened.')
interval = setInterval(() => {
websocket.send('PING')
}, 10000)
}
websocket.onmessage = (event) => {
const data = JSON.parse(event.data)
const queryKey = [...data.entity, data.id].filter(Boolean)
queryClient.invalidateQueries({ queryKey })
console.log('[Message Bus] invalidating: ' + event.data)
}
websocket.onclose = (e) => {
console.warn('[Message Bus] closed!', e)
interval.close()
}
websocket.onerror = () => {
console.error('[Message Bus] errored!')
}
return () => {
websocket.close()
}
}, [queryClient])
}

View File

@ -10,7 +10,7 @@
import { Route as rootRouteImport } from './routes/__root'
import { Route as SidebarRouteImport } from './routes/_sidebar'
import { Route as SidebarIndexRouteImport } from './routes/_sidebar/index'
import { Route as IndexRouteImport } from './routes/index'
import { Route as DemoTanstackQueryRouteImport } from './routes/demo.tanstack-query'
import { Route as SidebarScannerRouteImport } from './routes/_sidebar/scanner'
import { Route as SidebarNotificationsRouteImport } from './routes/_sidebar/notifications'
@ -33,15 +33,23 @@ import { Route as SidebarCrmKostenstelleRouteImport } from './routes/_sidebar/cr
import { Route as SidebarCrmFirmenRouteImport } from './routes/_sidebar/crm/firmen'
import { Route as SidebarCrmDienstleisterRouteImport } from './routes/_sidebar/crm/dienstleister'
import { Route as SidebarCrmAnsprechpartnerRouteImport } from './routes/_sidebar/crm/ansprechpartner'
import { Route as SidebarProjectsViewIdRouteImport } from './routes/_sidebar/projects/view/$id'
import { Route as SidebarProjectsViewIdIndexRouteImport } from './routes/_sidebar/projects/view/$id/index'
import { Route as SidebarProjectsViewIdTodosRouteImport } from './routes/_sidebar/projects/view/$id/todos'
import { Route as SidebarProjectsViewIdTimelineRouteImport } from './routes/_sidebar/projects/view/$id/timeline'
import { Route as SidebarProjectsViewIdPersonalRouteImport } from './routes/_sidebar/projects/view/$id/personal'
import { Route as SidebarProjectsViewIdFinanceRouteImport } from './routes/_sidebar/projects/view/$id/finance'
import { Route as SidebarProjectsViewIdEquipmentRouteImport } from './routes/_sidebar/projects/view/$id/equipment'
import { Route as SidebarProjectsViewIdAuditRouteImport } from './routes/_sidebar/projects/view/$id/audit'
const SidebarRoute = SidebarRouteImport.update({
id: '/_sidebar',
getParentRoute: () => rootRouteImport,
} as any)
const SidebarIndexRoute = SidebarIndexRouteImport.update({
const IndexRoute = IndexRouteImport.update({
id: '/',
path: '/',
getParentRoute: () => SidebarRoute,
getParentRoute: () => rootRouteImport,
} as any)
const DemoTanstackQueryRoute = DemoTanstackQueryRouteImport.update({
id: '/demo/tanstack-query',
@ -154,8 +162,56 @@ const SidebarCrmAnsprechpartnerRoute =
path: '/crm/ansprechpartner',
getParentRoute: () => SidebarRoute,
} as any)
const SidebarProjectsViewIdRoute = SidebarProjectsViewIdRouteImport.update({
id: '/projects/view/$id',
path: '/projects/view/$id',
getParentRoute: () => SidebarRoute,
} as any)
const SidebarProjectsViewIdIndexRoute =
SidebarProjectsViewIdIndexRouteImport.update({
id: '/',
path: '/',
getParentRoute: () => SidebarProjectsViewIdRoute,
} as any)
const SidebarProjectsViewIdTodosRoute =
SidebarProjectsViewIdTodosRouteImport.update({
id: '/todos',
path: '/todos',
getParentRoute: () => SidebarProjectsViewIdRoute,
} as any)
const SidebarProjectsViewIdTimelineRoute =
SidebarProjectsViewIdTimelineRouteImport.update({
id: '/timeline',
path: '/timeline',
getParentRoute: () => SidebarProjectsViewIdRoute,
} as any)
const SidebarProjectsViewIdPersonalRoute =
SidebarProjectsViewIdPersonalRouteImport.update({
id: '/personal',
path: '/personal',
getParentRoute: () => SidebarProjectsViewIdRoute,
} as any)
const SidebarProjectsViewIdFinanceRoute =
SidebarProjectsViewIdFinanceRouteImport.update({
id: '/finance',
path: '/finance',
getParentRoute: () => SidebarProjectsViewIdRoute,
} as any)
const SidebarProjectsViewIdEquipmentRoute =
SidebarProjectsViewIdEquipmentRouteImport.update({
id: '/equipment',
path: '/equipment',
getParentRoute: () => SidebarProjectsViewIdRoute,
} as any)
const SidebarProjectsViewIdAuditRoute =
SidebarProjectsViewIdAuditRouteImport.update({
id: '/audit',
path: '/audit',
getParentRoute: () => SidebarProjectsViewIdRoute,
} as any)
export interface FileRoutesByFullPath {
'/': typeof IndexRoute
'/about': typeof SidebarAboutRoute
'/changelog': typeof SidebarChangelogRoute
'/dashboard': typeof SidebarDashboardRoute
@ -165,7 +221,6 @@ export interface FileRoutesByFullPath {
'/notifications': typeof SidebarNotificationsRoute
'/scanner': typeof SidebarScannerRoute
'/demo/tanstack-query': typeof DemoTanstackQueryRoute
'/': typeof SidebarIndexRoute
'/crm/ansprechpartner': typeof SidebarCrmAnsprechpartnerRoute
'/crm/dienstleister': typeof SidebarCrmDienstleisterRoute
'/crm/firmen': typeof SidebarCrmFirmenRoute
@ -179,8 +234,17 @@ export interface FileRoutesByFullPath {
'/crm': typeof SidebarCrmIndexRoute
'/projects': typeof SidebarProjectsIndexRoute
'/storage': typeof SidebarStorageIndexRoute
'/projects/view/$id': typeof SidebarProjectsViewIdRouteWithChildren
'/projects/view/$id/audit': typeof SidebarProjectsViewIdAuditRoute
'/projects/view/$id/equipment': typeof SidebarProjectsViewIdEquipmentRoute
'/projects/view/$id/finance': typeof SidebarProjectsViewIdFinanceRoute
'/projects/view/$id/personal': typeof SidebarProjectsViewIdPersonalRoute
'/projects/view/$id/timeline': typeof SidebarProjectsViewIdTimelineRoute
'/projects/view/$id/todos': typeof SidebarProjectsViewIdTodosRoute
'/projects/view/$id/': typeof SidebarProjectsViewIdIndexRoute
}
export interface FileRoutesByTo {
'/': typeof IndexRoute
'/about': typeof SidebarAboutRoute
'/changelog': typeof SidebarChangelogRoute
'/dashboard': typeof SidebarDashboardRoute
@ -190,7 +254,6 @@ export interface FileRoutesByTo {
'/notifications': typeof SidebarNotificationsRoute
'/scanner': typeof SidebarScannerRoute
'/demo/tanstack-query': typeof DemoTanstackQueryRoute
'/': typeof SidebarIndexRoute
'/crm/ansprechpartner': typeof SidebarCrmAnsprechpartnerRoute
'/crm/dienstleister': typeof SidebarCrmDienstleisterRoute
'/crm/firmen': typeof SidebarCrmFirmenRoute
@ -204,9 +267,17 @@ export interface FileRoutesByTo {
'/crm': typeof SidebarCrmIndexRoute
'/projects': typeof SidebarProjectsIndexRoute
'/storage': typeof SidebarStorageIndexRoute
'/projects/view/$id/audit': typeof SidebarProjectsViewIdAuditRoute
'/projects/view/$id/equipment': typeof SidebarProjectsViewIdEquipmentRoute
'/projects/view/$id/finance': typeof SidebarProjectsViewIdFinanceRoute
'/projects/view/$id/personal': typeof SidebarProjectsViewIdPersonalRoute
'/projects/view/$id/timeline': typeof SidebarProjectsViewIdTimelineRoute
'/projects/view/$id/todos': typeof SidebarProjectsViewIdTodosRoute
'/projects/view/$id': typeof SidebarProjectsViewIdIndexRoute
}
export interface FileRoutesById {
__root__: typeof rootRouteImport
'/': typeof IndexRoute
'/_sidebar': typeof SidebarRouteWithChildren
'/_sidebar/about': typeof SidebarAboutRoute
'/_sidebar/changelog': typeof SidebarChangelogRoute
@ -217,7 +288,6 @@ export interface FileRoutesById {
'/_sidebar/notifications': typeof SidebarNotificationsRoute
'/_sidebar/scanner': typeof SidebarScannerRoute
'/demo/tanstack-query': typeof DemoTanstackQueryRoute
'/_sidebar/': typeof SidebarIndexRoute
'/_sidebar/crm/ansprechpartner': typeof SidebarCrmAnsprechpartnerRoute
'/_sidebar/crm/dienstleister': typeof SidebarCrmDienstleisterRoute
'/_sidebar/crm/firmen': typeof SidebarCrmFirmenRoute
@ -231,10 +301,19 @@ export interface FileRoutesById {
'/_sidebar/crm/': typeof SidebarCrmIndexRoute
'/_sidebar/projects/': typeof SidebarProjectsIndexRoute
'/_sidebar/storage/': typeof SidebarStorageIndexRoute
'/_sidebar/projects/view/$id': typeof SidebarProjectsViewIdRouteWithChildren
'/_sidebar/projects/view/$id/audit': typeof SidebarProjectsViewIdAuditRoute
'/_sidebar/projects/view/$id/equipment': typeof SidebarProjectsViewIdEquipmentRoute
'/_sidebar/projects/view/$id/finance': typeof SidebarProjectsViewIdFinanceRoute
'/_sidebar/projects/view/$id/personal': typeof SidebarProjectsViewIdPersonalRoute
'/_sidebar/projects/view/$id/timeline': typeof SidebarProjectsViewIdTimelineRoute
'/_sidebar/projects/view/$id/todos': typeof SidebarProjectsViewIdTodosRoute
'/_sidebar/projects/view/$id/': typeof SidebarProjectsViewIdIndexRoute
}
export interface FileRouteTypes {
fileRoutesByFullPath: FileRoutesByFullPath
fullPaths:
| '/'
| '/about'
| '/changelog'
| '/dashboard'
@ -244,7 +323,6 @@ export interface FileRouteTypes {
| '/notifications'
| '/scanner'
| '/demo/tanstack-query'
| '/'
| '/crm/ansprechpartner'
| '/crm/dienstleister'
| '/crm/firmen'
@ -258,8 +336,17 @@ export interface FileRouteTypes {
| '/crm'
| '/projects'
| '/storage'
| '/projects/view/$id'
| '/projects/view/$id/audit'
| '/projects/view/$id/equipment'
| '/projects/view/$id/finance'
| '/projects/view/$id/personal'
| '/projects/view/$id/timeline'
| '/projects/view/$id/todos'
| '/projects/view/$id/'
fileRoutesByTo: FileRoutesByTo
to:
| '/'
| '/about'
| '/changelog'
| '/dashboard'
@ -269,7 +356,6 @@ export interface FileRouteTypes {
| '/notifications'
| '/scanner'
| '/demo/tanstack-query'
| '/'
| '/crm/ansprechpartner'
| '/crm/dienstleister'
| '/crm/firmen'
@ -283,8 +369,16 @@ export interface FileRouteTypes {
| '/crm'
| '/projects'
| '/storage'
| '/projects/view/$id/audit'
| '/projects/view/$id/equipment'
| '/projects/view/$id/finance'
| '/projects/view/$id/personal'
| '/projects/view/$id/timeline'
| '/projects/view/$id/todos'
| '/projects/view/$id'
id:
| '__root__'
| '/'
| '/_sidebar'
| '/_sidebar/about'
| '/_sidebar/changelog'
@ -295,7 +389,6 @@ export interface FileRouteTypes {
| '/_sidebar/notifications'
| '/_sidebar/scanner'
| '/demo/tanstack-query'
| '/_sidebar/'
| '/_sidebar/crm/ansprechpartner'
| '/_sidebar/crm/dienstleister'
| '/_sidebar/crm/firmen'
@ -309,9 +402,18 @@ export interface FileRouteTypes {
| '/_sidebar/crm/'
| '/_sidebar/projects/'
| '/_sidebar/storage/'
| '/_sidebar/projects/view/$id'
| '/_sidebar/projects/view/$id/audit'
| '/_sidebar/projects/view/$id/equipment'
| '/_sidebar/projects/view/$id/finance'
| '/_sidebar/projects/view/$id/personal'
| '/_sidebar/projects/view/$id/timeline'
| '/_sidebar/projects/view/$id/todos'
| '/_sidebar/projects/view/$id/'
fileRoutesById: FileRoutesById
}
export interface RootRouteChildren {
IndexRoute: typeof IndexRoute
SidebarRoute: typeof SidebarRouteWithChildren
DemoTanstackQueryRoute: typeof DemoTanstackQueryRoute
}
@ -325,12 +427,12 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof SidebarRouteImport
parentRoute: typeof rootRouteImport
}
'/_sidebar/': {
id: '/_sidebar/'
'/': {
id: '/'
path: '/'
fullPath: '/'
preLoaderRoute: typeof SidebarIndexRouteImport
parentRoute: typeof SidebarRoute
preLoaderRoute: typeof IndexRouteImport
parentRoute: typeof rootRouteImport
}
'/demo/tanstack-query': {
id: '/demo/tanstack-query'
@ -486,9 +588,90 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof SidebarCrmAnsprechpartnerRouteImport
parentRoute: typeof SidebarRoute
}
'/_sidebar/projects/view/$id': {
id: '/_sidebar/projects/view/$id'
path: '/projects/view/$id'
fullPath: '/projects/view/$id'
preLoaderRoute: typeof SidebarProjectsViewIdRouteImport
parentRoute: typeof SidebarRoute
}
'/_sidebar/projects/view/$id/': {
id: '/_sidebar/projects/view/$id/'
path: '/'
fullPath: '/projects/view/$id/'
preLoaderRoute: typeof SidebarProjectsViewIdIndexRouteImport
parentRoute: typeof SidebarProjectsViewIdRoute
}
'/_sidebar/projects/view/$id/todos': {
id: '/_sidebar/projects/view/$id/todos'
path: '/todos'
fullPath: '/projects/view/$id/todos'
preLoaderRoute: typeof SidebarProjectsViewIdTodosRouteImport
parentRoute: typeof SidebarProjectsViewIdRoute
}
'/_sidebar/projects/view/$id/timeline': {
id: '/_sidebar/projects/view/$id/timeline'
path: '/timeline'
fullPath: '/projects/view/$id/timeline'
preLoaderRoute: typeof SidebarProjectsViewIdTimelineRouteImport
parentRoute: typeof SidebarProjectsViewIdRoute
}
'/_sidebar/projects/view/$id/personal': {
id: '/_sidebar/projects/view/$id/personal'
path: '/personal'
fullPath: '/projects/view/$id/personal'
preLoaderRoute: typeof SidebarProjectsViewIdPersonalRouteImport
parentRoute: typeof SidebarProjectsViewIdRoute
}
'/_sidebar/projects/view/$id/finance': {
id: '/_sidebar/projects/view/$id/finance'
path: '/finance'
fullPath: '/projects/view/$id/finance'
preLoaderRoute: typeof SidebarProjectsViewIdFinanceRouteImport
parentRoute: typeof SidebarProjectsViewIdRoute
}
'/_sidebar/projects/view/$id/equipment': {
id: '/_sidebar/projects/view/$id/equipment'
path: '/equipment'
fullPath: '/projects/view/$id/equipment'
preLoaderRoute: typeof SidebarProjectsViewIdEquipmentRouteImport
parentRoute: typeof SidebarProjectsViewIdRoute
}
'/_sidebar/projects/view/$id/audit': {
id: '/_sidebar/projects/view/$id/audit'
path: '/audit'
fullPath: '/projects/view/$id/audit'
preLoaderRoute: typeof SidebarProjectsViewIdAuditRouteImport
parentRoute: typeof SidebarProjectsViewIdRoute
}
}
}
interface SidebarProjectsViewIdRouteChildren {
SidebarProjectsViewIdAuditRoute: typeof SidebarProjectsViewIdAuditRoute
SidebarProjectsViewIdEquipmentRoute: typeof SidebarProjectsViewIdEquipmentRoute
SidebarProjectsViewIdFinanceRoute: typeof SidebarProjectsViewIdFinanceRoute
SidebarProjectsViewIdPersonalRoute: typeof SidebarProjectsViewIdPersonalRoute
SidebarProjectsViewIdTimelineRoute: typeof SidebarProjectsViewIdTimelineRoute
SidebarProjectsViewIdTodosRoute: typeof SidebarProjectsViewIdTodosRoute
SidebarProjectsViewIdIndexRoute: typeof SidebarProjectsViewIdIndexRoute
}
const SidebarProjectsViewIdRouteChildren: SidebarProjectsViewIdRouteChildren = {
SidebarProjectsViewIdAuditRoute: SidebarProjectsViewIdAuditRoute,
SidebarProjectsViewIdEquipmentRoute: SidebarProjectsViewIdEquipmentRoute,
SidebarProjectsViewIdFinanceRoute: SidebarProjectsViewIdFinanceRoute,
SidebarProjectsViewIdPersonalRoute: SidebarProjectsViewIdPersonalRoute,
SidebarProjectsViewIdTimelineRoute: SidebarProjectsViewIdTimelineRoute,
SidebarProjectsViewIdTodosRoute: SidebarProjectsViewIdTodosRoute,
SidebarProjectsViewIdIndexRoute: SidebarProjectsViewIdIndexRoute,
}
const SidebarProjectsViewIdRouteWithChildren =
SidebarProjectsViewIdRoute._addFileChildren(
SidebarProjectsViewIdRouteChildren,
)
interface SidebarRouteChildren {
SidebarAboutRoute: typeof SidebarAboutRoute
SidebarChangelogRoute: typeof SidebarChangelogRoute
@ -498,7 +681,6 @@ interface SidebarRouteChildren {
SidebarKanbanRoute: typeof SidebarKanbanRoute
SidebarNotificationsRoute: typeof SidebarNotificationsRoute
SidebarScannerRoute: typeof SidebarScannerRoute
SidebarIndexRoute: typeof SidebarIndexRoute
SidebarCrmAnsprechpartnerRoute: typeof SidebarCrmAnsprechpartnerRoute
SidebarCrmDienstleisterRoute: typeof SidebarCrmDienstleisterRoute
SidebarCrmFirmenRoute: typeof SidebarCrmFirmenRoute
@ -512,6 +694,7 @@ interface SidebarRouteChildren {
SidebarCrmIndexRoute: typeof SidebarCrmIndexRoute
SidebarProjectsIndexRoute: typeof SidebarProjectsIndexRoute
SidebarStorageIndexRoute: typeof SidebarStorageIndexRoute
SidebarProjectsViewIdRoute: typeof SidebarProjectsViewIdRouteWithChildren
}
const SidebarRouteChildren: SidebarRouteChildren = {
@ -523,7 +706,6 @@ const SidebarRouteChildren: SidebarRouteChildren = {
SidebarKanbanRoute: SidebarKanbanRoute,
SidebarNotificationsRoute: SidebarNotificationsRoute,
SidebarScannerRoute: SidebarScannerRoute,
SidebarIndexRoute: SidebarIndexRoute,
SidebarCrmAnsprechpartnerRoute: SidebarCrmAnsprechpartnerRoute,
SidebarCrmDienstleisterRoute: SidebarCrmDienstleisterRoute,
SidebarCrmFirmenRoute: SidebarCrmFirmenRoute,
@ -537,12 +719,14 @@ const SidebarRouteChildren: SidebarRouteChildren = {
SidebarCrmIndexRoute: SidebarCrmIndexRoute,
SidebarProjectsIndexRoute: SidebarProjectsIndexRoute,
SidebarStorageIndexRoute: SidebarStorageIndexRoute,
SidebarProjectsViewIdRoute: SidebarProjectsViewIdRouteWithChildren,
}
const SidebarRouteWithChildren =
SidebarRoute._addFileChildren(SidebarRouteChildren)
const rootRouteChildren: RootRouteChildren = {
IndexRoute: IndexRoute,
SidebarRoute: SidebarRouteWithChildren,
DemoTanstackQueryRoute: DemoTanstackQueryRoute,
}

View File

@ -5,6 +5,8 @@ import {
} from '@tanstack/react-router'
import { TanStackRouterDevtoolsPanel } from '@tanstack/react-router-devtools'
import { TanstackDevtools } from '@tanstack/react-devtools'
import { useEffect } from 'react'
import { enableDragDropTouch } from '@dragdroptouch/drag-drop-touch'
import StoreDevtools from '../lib/demo-store-devtools'
@ -13,6 +15,7 @@ import TanStackQueryDevtools from '../integrations/tanstack-query/devtools'
import appCss from '../styles.css?url'
import type { QueryClient } from '@tanstack/react-query'
import { useReactQuerySubscription } from '@/lib/utils'
interface MyRouterContext {
queryClient: QueryClient
@ -29,7 +32,7 @@ export const Route = createRootRouteWithContext<MyRouterContext>()({
content: 'width=device-width, initial-scale=1',
},
{
title: 'TanStack Start Starter',
title: 'Eventory',
},
],
links: [
@ -44,6 +47,11 @@ export const Route = createRootRouteWithContext<MyRouterContext>()({
})
function RootDocument({ children }: { children: React.ReactNode }) {
useReactQuerySubscription()
useEffect(() => {
enableDragDropTouch()
}, [])
return (
<html lang="en">
<head>

View File

@ -16,7 +16,7 @@ function RouteComponent() {
return (
<SidebarProvider>
<AppSidebar />
<SidebarInset>
<SidebarInset className="sidebar-width block max-h-screen">
<header className="flex h-16 shrink-0 items-center gap-2">
<div className="flex items-center gap-2 px-4">
<SidebarTrigger className="-ml-1" />
@ -27,7 +27,7 @@ function RouteComponent() {
<Breadcrumbs />
</div>
</header>
<div className="p-4 pt-0 h-full w-full flex-grow">
<div className="p-4 pt-0 full-h w-full overflow-scroll">
<Outlet />
</div>
</SidebarInset>

View File

@ -1,15 +0,0 @@
import { createFileRoute } from '@tanstack/react-router'
import Session from '@/features/Auth/components/session'
export const Route = createFileRoute('/_sidebar/')({
component: App,
})
function App() {
return (
<div>
<h1 className="text-xl">Hi Konstantin Hintermayer!</h1>
<Session />
</div>
)
}

View File

@ -1,9 +1,288 @@
import { createFileRoute } from '@tanstack/react-router'
import KanbanColumn from '@/features/Kanban/components/KanbanColumn'
import Kanban from '@/features/Kanban/components/Kanban'
import KanbanCard from '@/features/Kanban/components/KanbanCard'
import KanbanDropzone from '@/features/Kanban/components/KanbanDropzone'
export const Route = createFileRoute('/_sidebar/kanban')({
component: RouteComponent,
})
function RouteComponent() {
return <div>Hello "/_sidebar/kanban"!</div>
const kanbanboard = {
columns: [
{
name: 'Todo',
itemCount: 5,
children: [
{
name: 'Hello World!',
path: 'https://google.com',
description: 'Das ist eine Testkarte',
labels: [
{
name: 'flow::7 Done',
className: 'bg-emerald-500',
},
{
name: 'Wichtig',
className: 'bg-red-500',
},
],
},
],
},
{
name: 'Doing',
itemCount: 6,
children: [
{
name: 'Hello World!',
path: 'https://google.com',
description: 'Das ist eine Testkarte',
labels: [
{
name: 'flow::7 Done',
className: 'bg-emerald-500',
},
{
name: 'Wichtig',
className: 'bg-red-500',
},
],
},
],
},
{
name: 'Done',
itemCount: 20,
children: [
{
name: 'Hello World!',
path: 'https://google.com',
description: 'Das ist eine Testkarte',
labels: [
{
name: 'flow::7 Done',
className: 'bg-emerald-500',
},
{
name: 'Wichtig',
className: 'bg-red-500',
},
],
},
],
},
{
name: 'Todo',
itemCount: 5,
children: [
{
name: 'Hello World!',
path: 'https://google.com',
description: 'Das ist eine Testkarte',
labels: [
{
name: 'flow::7 Done',
className: 'bg-emerald-500',
},
{
name: 'Wichtig',
className: 'bg-red-500',
},
],
},
],
},
{
name: 'Doing',
itemCount: 6,
children: [
{
name: 'Hello World!',
path: 'https://google.com',
description: 'Das ist eine Testkarte',
labels: [
{
name: 'flow::7 Done',
className: 'bg-emerald-500',
},
{
name: 'Wichtig',
className: 'bg-red-500',
},
],
},
],
},
{
name: 'Done',
itemCount: 20,
children: [
{
name: 'Hello World!',
path: 'https://google.com',
description: 'Das ist eine Testkarte',
labels: [
{
name: 'flow::7 Done',
className: 'bg-emerald-500',
},
{
name: 'Wichtig',
className: 'bg-red-500',
},
],
},
],
},
{
name: 'Todo',
itemCount: 5,
children: [
{
name: 'Hello World!',
path: 'https://google.com',
description: 'Das ist eine Testkarte',
labels: [
{
name: 'flow::7 Done',
className: 'bg-emerald-500',
},
{
name: 'Wichtig',
className: 'bg-red-500',
},
],
},
],
},
{
name: 'Doing',
itemCount: 6,
children: [
{
name: 'Hello World!',
path: 'https://google.com',
description: 'Das ist eine Testkarte',
labels: [
{
name: 'flow::7 Done',
className: 'bg-emerald-500',
},
{
name: 'Wichtig',
className: 'bg-red-500',
},
],
},
],
},
{
name: 'Done',
itemCount: 20,
children: [
{
name: 'Hello World!',
path: 'https://google.com',
description: 'Das ist eine Testkarte',
labels: [
{
name: 'flow::7 Done',
className: 'bg-emerald-500',
},
{
name: 'Wichtig',
className: 'bg-red-500',
},
],
},
],
},
{
name: 'Todo',
itemCount: 5,
children: [
{
name: 'Hello World!',
path: 'https://google.com',
description: 'Das ist eine Testkarte',
labels: [
{
name: 'flow::7 Done',
className: 'bg-emerald-500',
},
{
name: 'Wichtig',
className: 'bg-red-500',
},
],
},
],
},
{
name: 'Doing',
itemCount: 6,
children: [
{
name: 'Hello World!',
path: 'https://google.com',
description: 'Das ist eine Testkarte',
labels: [
{
name: 'flow::7 Done',
className: 'bg-emerald-500',
},
{
name: 'Wichtig',
className: 'bg-red-500',
},
],
},
],
},
{
name: 'Done',
itemCount: 20,
children: [
{
name: 'Hello World!',
path: 'https://google.com',
description: 'Das ist eine Testkarte',
labels: [
{
name: 'flow::7 Done',
className: 'bg-emerald-500',
},
{
name: 'Wichtig',
className: 'bg-red-500',
},
],
},
],
},
],
}
function RouteComponent() {
return (
<div className="h-full w-full">
<Kanban>
{kanbanboard.columns.map((val) => {
return (
<>
<KanbanColumn name={val.name} itemCount={val.itemCount}>
{val.children.map((card) => {
return <KanbanCard card={card} />
})}
</KanbanColumn>
<KanbanDropzone />
</>
)
})}
</Kanban>
</div>
)
}

View File

@ -1,9 +1,10 @@
import { createFileRoute } from '@tanstack/react-router'
import ProjectsTable from '@/features/Projects/components/table'
export const Route = createFileRoute('/_sidebar/projects/')({
component: RouteComponent,
})
function RouteComponent() {
return <div>Hello "/_sidebar/projects/"!</div>
return <ProjectsTable />
}

View File

@ -0,0 +1,73 @@
import {
Link,
Outlet,
createFileRoute,
useLocation,
useRouter,
} from '@tanstack/react-router'
import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs'
import { getProjectQueryObject, useProject } from '@/features/Projects/queries'
export const Route = createFileRoute('/_sidebar/projects/view/$id')({
component: RouteComponent,
loader: async ({ params: { id }, context }) => {
// await context.queryClient.ensureQueryData(getProjectQueryObject(Number(id)))
},
})
function RouteComponent() {
const { id } = Route.useParams()
const router = useLocation()
const { data, isLoading } = useProject(Number(id))
const res = router.pathname.split('/')
const val = res[res.length - 1]
if (isLoading) return <div>Loading...</div>
return (
<>
<div className="my-2 text-xl">{data?.name + ' #' + data?.ID}</div>
<Tabs defaultValue={val} className="w-full">
<TabsList className="w-full overflow-auto">
<TabsTrigger value={id} asChild>
<Link to="/projects/view/$id" params={{ id }}>
General
</Link>
</TabsTrigger>
<TabsTrigger value="timeline" asChild>
<Link to="/projects/view/$id/timeline" params={{ id }}>
Timeline
</Link>
</TabsTrigger>
<TabsTrigger value="equipment" asChild>
<Link to="/projects/view/$id/equipment" params={{ id }}>
Equipment
</Link>
</TabsTrigger>
<TabsTrigger value="personal" asChild>
<Link to="/projects/view/$id/personal" params={{ id }}>
Personal
</Link>
</TabsTrigger>
<TabsTrigger value="finance" asChild>
<Link to="/projects/view/$id/finance" params={{ id }}>
Finanzen
</Link>
</TabsTrigger>
<TabsTrigger value="todos" asChild>
<Link to="/projects/view/$id/todos" params={{ id }}>
Todos
</Link>
</TabsTrigger>
<TabsTrigger value="audit" asChild>
<Link to="/projects/view/$id/audit" params={{ id }}>
Audit
</Link>
</TabsTrigger>
</TabsList>
</Tabs>
<Outlet />
</>
)
}

View File

@ -0,0 +1,9 @@
import { createFileRoute } from '@tanstack/react-router'
export const Route = createFileRoute('/_sidebar/projects/view/$id/audit')({
component: RouteComponent,
})
function RouteComponent() {
return <div>Hello "/_sidebar/projects/view/$id/audit"!</div>
}

View File

@ -0,0 +1,9 @@
import { createFileRoute } from '@tanstack/react-router'
export const Route = createFileRoute('/_sidebar/projects/view/$id/equipment')({
component: RouteComponent,
})
function RouteComponent() {
return <div>Hello "/_sidebar/projects/view/$id/equipment"!</div>
}

View File

@ -0,0 +1,9 @@
import { createFileRoute } from '@tanstack/react-router'
export const Route = createFileRoute('/_sidebar/projects/view/$id/finance')({
component: RouteComponent,
})
function RouteComponent() {
return <div>Hello "/_sidebar/projects/view/$id/finance"!</div>
}

View File

@ -0,0 +1,407 @@
import { useForm } from '@tanstack/react-form'
import { createFileRoute } from '@tanstack/react-router'
import z from 'zod'
import { Button } from '@/components/ui/button'
import {
Field,
FieldContent,
FieldDescription,
FieldError,
FieldGroup,
FieldLabel,
FieldLegend,
FieldSeparator,
} from '@/components/ui/field'
import { Input } from '@/components/ui/input'
import {
InputGroup,
InputGroupAddon,
InputGroupText,
InputGroupTextarea,
} from '@/components/ui/input-group'
import { useProject, useProjectEdit } from '@/features/Projects/queries'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
export const Route = createFileRoute('/_sidebar/projects/view/$id/')({
component: RouteComponent,
})
const managers = [
{
id: 1,
name: 'Konstantin Hintermayer',
},
{
id: 2,
name: 'ShadCN',
},
]
const projectTypes = [
{
name: 'Production',
},
{
name: 'Tour',
},
{
name: 'Dry Hire',
},
{
name: 'Verkauf',
},
]
const statusse = [
{
name: 'Cancelled',
},
{
name: 'Inquiry',
},
{
name: 'Concept',
},
{
name: 'Pending',
},
{
name: 'Confirmed',
},
{
name: 'Packed',
},
{
name: 'On location',
},
{
name: 'Returned',
},
]
const clients = [
{
id: 1,
name: 'VYZE',
},
{
id: 2,
name: 'Handig Eekhoorn',
},
{
id: 3,
name: 'Adrian',
},
]
const formSchema = z.object({
title: z
.string()
.min(5, 'Bug title must be at least 5 characters.')
.max(32, 'Bug title must be at most 32 characters.'),
description: z
.string()
.min(20, 'Description must be at least 20 characters.')
.max(200, 'Description must be at most 200 characters.'),
manager: z.number(),
type: z.string(),
status: z.string(),
client: z.number(),
})
function RouteComponent() {
const { id } = Route.useParams()
const { data } = useProject(Number(id))
const { mutate } = useProjectEdit(Number(id))
const form = useForm({
defaultValues: {
title: data?.name,
description: data?.description,
manager: 1,
type: 'Tour',
status: 'Confirmed',
client: 1,
},
validators: {
onSubmit: formSchema,
},
onSubmit: ({ value }) => {
mutate({
ID: Number(id),
name: value.title!,
description: value.description!,
icon: '',
MandantID: data?.MandantID,
})
},
})
return (
<>
<form
id="project-esther-graf-general-form"
className="w-2xl"
onSubmit={(e) => {
e.preventDefault()
form.handleSubmit()
}}
>
<FieldGroup>
<FieldGroup>
<FieldLegend>Projekt Info</FieldLegend>
<FieldDescription>
Allgemeine Infos über das Projekt
</FieldDescription>
<FieldGroup>
<form.Field
name="title"
children={(field) => {
const isInvalid =
field.state.meta.isTouched && !field.state.meta.isValid
return (
<Field data-invalid={isInvalid}>
<FieldLabel htmlFor={field.name}>Projektname</FieldLabel>
<Input
id={field.name}
name={field.name}
value={field.state.value}
onBlur={field.handleBlur}
onChange={(e) => field.handleChange(e.target.value)}
aria-invalid={isInvalid}
placeholder="Login button not working on mobile"
autoComplete="off"
/>
{isInvalid && (
<FieldError errors={field.state.meta.errors} />
)}
</Field>
)
}}
/>
<form.Field
name="description"
children={(field) => {
const isInvalid =
field.state.meta.isTouched && !field.state.meta.isValid
return (
<Field data-invalid={isInvalid}>
<FieldLabel htmlFor={field.name}>Beschreibung</FieldLabel>
<InputGroup>
<InputGroupTextarea
id={field.name}
name={field.name}
value={field.state.value}
onBlur={field.handleBlur}
onChange={(e) => field.handleChange(e.target.value)}
placeholder="I'm having an issue with the login button on mobile."
rows={6}
className="min-h-24 resize-none"
aria-invalid={isInvalid}
/>
<InputGroupAddon align="block-end">
<InputGroupText className="tabular-nums">
{field.state.value.length}/100 characters
</InputGroupText>
</InputGroupAddon>
</InputGroup>
<FieldDescription>
Allgemeine Infos zum Projekt
</FieldDescription>
{isInvalid && (
<FieldError errors={field.state.meta.errors} />
)}
</Field>
)
}}
/>
<form.Field
name="manager"
children={(field) => {
const isInvalid =
field.state.meta.isTouched && !field.state.meta.isValid
return (
<Field orientation="responsive" data-invalid={isInvalid}>
<FieldContent>
<FieldLabel htmlFor="form-tanstack-select-language">
Manager
</FieldLabel>
<FieldDescription>
Zuständiger für dieses Projekt
</FieldDescription>
{isInvalid && (
<FieldError errors={field.state.meta.errors} />
)}
</FieldContent>
<Select
name={field.name}
value={field.state.value.toString()}
onValueChange={(v) => field.handleChange(Number(v))}
>
<SelectTrigger
id="form-tanstack-select-language"
aria-invalid={isInvalid}
className="min-w-[120px]"
>
<SelectValue placeholder="Select" />
</SelectTrigger>
<SelectContent position="item-aligned">
{managers.map((manager) => (
<SelectItem
key={manager.id}
value={manager.id.toString()}
>
{manager.name}
</SelectItem>
))}
</SelectContent>
</Select>
</Field>
)
}}
/>
<form.Field
name="type"
children={(field) => {
const isInvalid =
field.state.meta.isTouched && !field.state.meta.isValid
return (
<Field orientation="responsive" data-invalid={isInvalid}>
<FieldContent>
<FieldLabel htmlFor="form-tanstack-select-language">
Projekttyp
</FieldLabel>
<FieldDescription>
Art des Projektes (Gibt voreinstellungen für Felder
wie zum Beispiel: Zahlungskonditionen vor)
</FieldDescription>
{isInvalid && (
<FieldError errors={field.state.meta.errors} />
)}
</FieldContent>
<Select
name={field.name}
value={field.state.value}
onValueChange={field.handleChange}
>
<SelectTrigger
id="form-tanstack-select-language"
aria-invalid={isInvalid}
className="min-w-[120px]"
>
<SelectValue placeholder="Select" />
</SelectTrigger>
<SelectContent position="item-aligned">
{projectTypes.map((prj) => (
<SelectItem key={prj.name} value={prj.name}>
{prj.name}
</SelectItem>
))}
</SelectContent>
</Select>
</Field>
)
}}
/>
<form.Field
name="status"
children={(field) => {
const isInvalid =
field.state.meta.isTouched && !field.state.meta.isValid
return (
<Field orientation="responsive" data-invalid={isInvalid}>
<FieldContent>
<FieldLabel htmlFor="form-tanstack-select-language">
Status
</FieldLabel>
{isInvalid && (
<FieldError errors={field.state.meta.errors} />
)}
</FieldContent>
<Select
name={field.name}
value={field.state.value}
onValueChange={field.handleChange}
>
<SelectTrigger
id="form-tanstack-select-language"
aria-invalid={isInvalid}
className="min-w-[120px]"
>
<SelectValue placeholder="Select" />
</SelectTrigger>
<SelectContent position="item-aligned">
{statusse.map((stat) => (
<SelectItem key={stat.name} value={stat.name}>
{stat.name}
</SelectItem>
))}
</SelectContent>
</Select>
</Field>
)
}}
/>
</FieldGroup>
</FieldGroup>
<FieldSeparator />
<FieldGroup>
<form.Field
name="client"
children={(field) => {
const isInvalid =
field.state.meta.isTouched && !field.state.meta.isValid
return (
<Field orientation="responsive" data-invalid={isInvalid}>
<FieldContent>
<FieldLabel htmlFor="form-tanstack-select-language">
Client
</FieldLabel>
{isInvalid && (
<FieldError errors={field.state.meta.errors} />
)}
</FieldContent>
<Select
name={field.name}
value={field.state.value.toString()}
onValueChange={(v) => field.handleChange(Number(v))}
>
<SelectTrigger
id="form-tanstack-select-language"
aria-invalid={isInvalid}
className="min-w-[120px]"
>
<SelectValue placeholder="Select" />
</SelectTrigger>
<SelectContent position="item-aligned">
{clients.map((stat) => (
<SelectItem key={stat.id} value={stat.id.toString()}>
{stat.name}
</SelectItem>
))}
</SelectContent>
</Select>
</Field>
)
}}
/>
</FieldGroup>
</FieldGroup>
</form>
<Field orientation="horizontal" className="mt-4">
<Button type="button" variant="outline" onClick={() => form.reset()}>
Reset
</Button>
<Button type="submit" form="project-esther-graf-general-form">
Submit
</Button>
</Field>
</>
)
}

View File

@ -0,0 +1,9 @@
import { createFileRoute } from '@tanstack/react-router'
export const Route = createFileRoute('/_sidebar/projects/view/$id/personal')({
component: RouteComponent,
})
function RouteComponent() {
return <div>Hello "/_sidebar/projects/view/$id/personal"!</div>
}

View File

@ -0,0 +1,9 @@
import { createFileRoute } from '@tanstack/react-router'
export const Route = createFileRoute('/_sidebar/projects/view/$id/timeline')({
component: RouteComponent,
})
function RouteComponent() {
return <div>Hello "/_sidebar/projects/view/$id/timeline"!</div>
}

View File

@ -0,0 +1,9 @@
import { createFileRoute } from '@tanstack/react-router'
export const Route = createFileRoute('/_sidebar/projects/view/$id/todos')({
component: RouteComponent,
})
function RouteComponent() {
return <div>Hello "/_sidebar/projects/view/$id/todos"!</div>
}

View File

@ -1,9 +1,10 @@
import { createFileRoute } from '@tanstack/react-router'
import Tiptap from '@/features/Editor/tiptap'
export const Route = createFileRoute('/_sidebar/storage/')({
component: RouteComponent,
})
function RouteComponent() {
return <div>Hello "/_sidebar/storage/"!</div>
return <Tiptap></Tiptap>
}

24
src/routes/index.tsx Normal file
View File

@ -0,0 +1,24 @@
import { createFileRoute, useNavigate } from '@tanstack/react-router'
import { LoginForm } from '@/components/login-form'
import { useProfile } from '@/features/Auth/queries'
export const Route = createFileRoute('/')({
component: App,
})
function App() {
const { data, isLoading } = useProfile()
const navigate = useNavigate()
if (data?.ID != null) {
navigate({ href: '/dashboard' })
}
return (
<div className="bg-muted flex min-h-svh flex-col items-center justify-center p-6 md:p-10">
<div className="w-full max-w-sm md:max-w-4xl">
<LoginForm />
</div>
</div>
)
}

View File

@ -136,3 +136,28 @@ code {
@apply bg-background text-foreground;
}
}
.sidebar-width {
@media (width >= 48rem /* 768px */) {
max-width: calc(100vw - var(--sidebar-width) - calc(var(--spacing) * 2));
max-height: calc(100vh - var(--spacing) * 4);
position: relative;
transition: max-width 200ms linear;
&:is(:where(.peer)[data-variant='inset'] ~ *) {
&:is(:where(.peer)[data-state='collapsed'] ~ *) {
max-width: calc(100vw - calc(var(--spacing) * 1));
}
}
}
}
.full-h {
/* max-height: calc(100% - var(--spacing) * 16); */
height: calc(100% - var(--spacing) * 16);
}
.table-height {
height: calc(100% - calc(var(--spacing) * 30));
}

148
src/types/data-grid.ts Normal file
View File

@ -0,0 +1,148 @@
import type { RowData } from "@tanstack/react-table";
export type RowHeightValue = "short" | "medium" | "tall" | "extra-tall";
export interface CellSelectOption {
label: string;
value: string;
}
export type Cell =
| {
variant: "short-text";
}
| {
variant: "long-text";
}
| {
variant: "number";
min?: number;
max?: number;
step?: number;
}
| {
variant: "select";
options: CellSelectOption[];
}
| {
variant: "multi-select";
options: CellSelectOption[];
}
| {
variant: "checkbox";
}
| {
variant: "date";
};
export interface UpdateCell {
rowIndex: number;
columnId: string;
value: unknown;
}
declare module "@tanstack/react-table" {
// biome-ignore lint/correctness/noUnusedVariables: TData and TValue are used in the ColumnMeta interface
interface ColumnMeta<TData extends RowData, TValue> {
label?: string;
cell?: Cell;
}
// biome-ignore lint/correctness/noUnusedVariables: TData is used in the TableMeta interface
interface TableMeta<TData extends RowData> {
dataGridRef?: React.RefObject<HTMLElement | null>;
focusedCell?: CellPosition | null;
editingCell?: CellPosition | null;
selectionState?: SelectionState;
searchOpen?: boolean;
isScrolling?: boolean;
getIsCellSelected?: (rowIndex: number, columnId: string) => boolean;
getIsSearchMatch?: (rowIndex: number, columnId: string) => boolean;
getIsActiveSearchMatch?: (rowIndex: number, columnId: string) => boolean;
onDataUpdate?: (props: UpdateCell | Array<UpdateCell>) => void;
onRowsDelete?: (rowIndices: number[]) => void | Promise<void>;
onColumnClick?: (columnId: string) => void;
onCellClick?: (
rowIndex: number,
columnId: string,
event?: React.MouseEvent,
) => void;
onCellDoubleClick?: (rowIndex: number, columnId: string) => void;
onCellMouseDown?: (
rowIndex: number,
columnId: string,
event: React.MouseEvent,
) => void;
onCellMouseEnter?: (
rowIndex: number,
columnId: string,
event: React.MouseEvent,
) => void;
onCellMouseUp?: () => void;
onCellContextMenu?: (
rowIndex: number,
columnId: string,
event: React.MouseEvent,
) => void;
onCellEditingStart?: (rowIndex: number, columnId: string) => void;
onCellEditingStop?: (opts?: {
direction?: NavigationDirection;
moveToNextRow?: boolean;
}) => void;
contextMenu?: ContextMenuState;
onContextMenuOpenChange?: (open: boolean) => void;
rowHeight?: RowHeightValue;
onRowHeightChange?: (value: RowHeightValue) => void;
onRowSelect?: (
rowIndex: number,
checked: boolean,
shiftKey: boolean,
) => void;
}
}
export interface CellPosition {
rowIndex: number;
columnId: string;
}
export interface CellRange {
start: CellPosition;
end: CellPosition;
}
export interface SelectionState {
selectedCells: Set<string>;
selectionRange: CellRange | null;
isSelecting: boolean;
}
export interface ContextMenuState {
open: boolean;
x: number;
y: number;
}
export type NavigationDirection =
| "up"
| "down"
| "left"
| "right"
| "home"
| "end"
| "ctrl+home"
| "ctrl+end"
| "pageup"
| "pagedown";
export interface SearchState {
searchMatches: CellPosition[];
matchIndex: number;
searchOpen: boolean;
onSearchOpenChange: (open: boolean) => void;
searchQuery: string;
onSearchQueryChange: (query: string) => void;
onSearch: (query: string) => void;
onNavigateToNextMatch: () => void;
onNavigateToPrevMatch: () => void;
}

53
src/types/data-table.ts Normal file
View File

@ -0,0 +1,53 @@
import type { ColumnSort, Row, RowData } from "@tanstack/react-table";
import type { DataTableConfig } from "@/components/data-table/data-table";
import type { FilterItemSchema } from "@/lib/parsers";
declare module "@tanstack/react-table" {
// biome-ignore lint/correctness/noUnusedVariables: TData is used in the TableMeta interface
interface TableMeta<TData extends RowData> {
queryKeys?: QueryKeys;
}
// biome-ignore lint/correctness/noUnusedVariables: TData and TValue are used in the ColumnMeta interface
interface ColumnMeta<TData extends RowData, TValue> {
label?: string;
placeholder?: string;
variant?: FilterVariant;
options?: Option[];
range?: [number, number];
unit?: string;
icon?: React.FC<React.SVGProps<SVGSVGElement>>;
}
}
export interface QueryKeys {
page: string;
perPage: string;
sort: string;
filters: string;
joinOperator: string;
}
export interface Option {
label: string;
value: string;
count?: number;
icon?: React.FC<React.SVGProps<SVGSVGElement>>;
}
export type FilterOperator = DataTableConfig["operators"][number];
export type FilterVariant = DataTableConfig["filterVariants"][number];
export type JoinOperator = DataTableConfig["joinOperators"][number];
export interface ExtendedColumnSort<TData> extends Omit<ColumnSort, "id"> {
id: Extract<keyof TData, string>;
}
export interface ExtendedColumnFilter<TData> extends FilterItemSchema {
id: Extract<keyof TData, string>;
}
export interface DataTableRowAction<TData> {
row: Row<TData>;
variant: "update" | "delete";
}

1
tmp/build-errors.log Normal file
View File

@ -0,0 +1 @@
exit status 1exit status 1exit status 1exit status 1exit status 1