Bulk commit: November work
This commit is contained in:
426
docs/ABAC_Database_Schema.md
Normal file
426
docs/ABAC_Database_Schema.md
Normal 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
1654
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
54
package.json
54
package.json
@ -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",
|
||||
|
||||
@ -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>
|
||||
|
||||
50
src/components/calendar-23.tsx
Normal file
50
src/components/calendar-23.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
66
src/components/data-table-column-header.tsx
Normal file
66
src/components/data-table-column-header.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
239
src/components/data-table.tsx
Normal file
239
src/components/data-table.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
92
src/components/login-form.tsx
Normal file
92
src/components/login-form.tsx
Normal 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'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>
|
||||
)
|
||||
}
|
||||
@ -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}>
|
||||
|
||||
46
src/components/ui/badge.tsx
Normal file
46
src/components/ui/badge.tsx
Normal 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 }
|
||||
216
src/components/ui/calendar.tsx
Normal file
216
src/components/ui/calendar.tsx
Normal 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 }
|
||||
92
src/components/ui/card.tsx
Normal file
92
src/components/ui/card.tsx
Normal 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,
|
||||
}
|
||||
30
src/components/ui/checkbox.tsx
Normal file
30
src/components/ui/checkbox.tsx
Normal 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 }
|
||||
182
src/components/ui/command.tsx
Normal file
182
src/components/ui/command.tsx
Normal 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,
|
||||
}
|
||||
141
src/components/ui/dialog.tsx
Normal file
141
src/components/ui/dialog.tsx
Normal 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
246
src/components/ui/field.tsx
Normal 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,
|
||||
}
|
||||
168
src/components/ui/input-group.tsx
Normal file
168
src/components/ui/input-group.tsx
Normal 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
28
src/components/ui/kbd.tsx
Normal 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 }
|
||||
583
src/components/ui/sortable.tsx
Normal file
583
src/components/ui/sortable.tsx
Normal 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
114
src/components/ui/table.tsx
Normal 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,
|
||||
}
|
||||
64
src/components/ui/tabs.tsx
Normal file
64
src/components/ui/tabs.tsx
Normal 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 }
|
||||
73
src/components/ui/toggle-group.tsx
Normal file
73
src/components/ui/toggle-group.tsx
Normal 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 }
|
||||
45
src/components/ui/toggle.tsx
Normal file
45
src/components/ui/toggle.tsx
Normal 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 }
|
||||
@ -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()
|
||||
}
|
||||
@ -14,6 +14,7 @@ export const env = createEnv({
|
||||
|
||||
client: {
|
||||
VITE_APP_TITLE: z.string().min(1).optional(),
|
||||
VITE_BACKEND_URI: z.string(),
|
||||
},
|
||||
|
||||
/**
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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()
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
@ -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',
|
||||
|
||||
40
src/features/Editor/tiptap.tsx
Normal file
40
src/features/Editor/tiptap.tsx
Normal 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
|
||||
11
src/features/Kanban/components/Kanban.tsx
Normal file
11
src/features/Kanban/components/Kanban.tsx
Normal 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
|
||||
15
src/features/Kanban/components/KanbanCard.tsx
Normal file
15
src/features/Kanban/components/KanbanCard.tsx
Normal 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
|
||||
59
src/features/Kanban/components/KanbanColumn.tsx
Normal file
59
src/features/Kanban/components/KanbanColumn.tsx
Normal 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
|
||||
28
src/features/Kanban/components/KanbanDropzone.tsx
Normal file
28
src/features/Kanban/components/KanbanDropzone.tsx
Normal 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
|
||||
@ -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',
|
||||
},
|
||||
|
||||
@ -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" />
|
||||
196
src/features/Projects/components/table.tsx
Normal file
196
src/features/Projects/components/table.tsx
Normal 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
|
||||
119
src/features/Projects/queries.ts
Normal file
119
src/features/Projects/queries.ts
Normal 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
62
src/lib/compose-refs.ts
Normal 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
17
src/lib/format.ts
Normal 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
99
src/lib/parsers.ts
Normal 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,
|
||||
),
|
||||
});
|
||||
};
|
||||
@ -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])
|
||||
}
|
||||
|
||||
@ -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,
|
||||
}
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@ -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 />
|
||||
}
|
||||
|
||||
73
src/routes/_sidebar/projects/view/$id.tsx
Normal file
73
src/routes/_sidebar/projects/view/$id.tsx
Normal 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 />
|
||||
</>
|
||||
)
|
||||
}
|
||||
9
src/routes/_sidebar/projects/view/$id/audit.tsx
Normal file
9
src/routes/_sidebar/projects/view/$id/audit.tsx
Normal 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>
|
||||
}
|
||||
9
src/routes/_sidebar/projects/view/$id/equipment.tsx
Normal file
9
src/routes/_sidebar/projects/view/$id/equipment.tsx
Normal 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>
|
||||
}
|
||||
9
src/routes/_sidebar/projects/view/$id/finance.tsx
Normal file
9
src/routes/_sidebar/projects/view/$id/finance.tsx
Normal 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>
|
||||
}
|
||||
407
src/routes/_sidebar/projects/view/$id/index.tsx
Normal file
407
src/routes/_sidebar/projects/view/$id/index.tsx
Normal 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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
9
src/routes/_sidebar/projects/view/$id/personal.tsx
Normal file
9
src/routes/_sidebar/projects/view/$id/personal.tsx
Normal 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>
|
||||
}
|
||||
9
src/routes/_sidebar/projects/view/$id/timeline.tsx
Normal file
9
src/routes/_sidebar/projects/view/$id/timeline.tsx
Normal 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>
|
||||
}
|
||||
9
src/routes/_sidebar/projects/view/$id/todos.tsx
Normal file
9
src/routes/_sidebar/projects/view/$id/todos.tsx
Normal 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>
|
||||
}
|
||||
@ -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
24
src/routes/index.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@ -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
148
src/types/data-grid.ts
Normal 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
53
src/types/data-table.ts
Normal 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
1
tmp/build-errors.log
Normal file
@ -0,0 +1 @@
|
||||
exit status 1exit status 1exit status 1exit status 1exit status 1
|
||||
Reference in New Issue
Block a user