Compare commits
18 Commits
3fec791f28
...
b4c03ff25e
| Author | SHA1 | Date | |
|---|---|---|---|
| b4c03ff25e | |||
| fd5716f39e | |||
| 954e73c007 | |||
| 48ef4f60df | |||
| e39ba6be97 | |||
| 3b7c246a47 | |||
| 5be55cec7c | |||
| dab77befc2 | |||
| 076cf8acd0 | |||
| ae8d547486 | |||
| 3d9e2452c4 | |||
| db9d6399dd | |||
| a897e8ead1 | |||
| c3e3018018 | |||
| be389c6cfa | |||
| ada8e03a04 | |||
| 956de68591 | |||
| e25f917b9a |
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"source": "/tmp/skill-selector-curated-3423638041",
|
"source": "/tmp/skill-selector-curated-3423638041",
|
||||||
"sourceType": "local",
|
"sourceType": "local",
|
||||||
"localPath": "/tmp/skill-selector-curated-3423638041/auth-implementation-patterns",
|
"localPath": "/tmp/skill-selector-curated-3423638041/auth-implementation-patterns",
|
||||||
"installedAt": "2026-04-07T00:45:24.777Z"
|
"installedAt": "2026-04-07T00:45:24.777Z"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"source": "/tmp/skill-selector-curated-3423638041",
|
"source": "/tmp/skill-selector-curated-3423638041",
|
||||||
"sourceType": "local",
|
"sourceType": "local",
|
||||||
"localPath": "/tmp/skill-selector-curated-3423638041/broken-authentication",
|
"localPath": "/tmp/skill-selector-curated-3423638041/broken-authentication",
|
||||||
"installedAt": "2026-04-07T00:45:24.780Z"
|
"installedAt": "2026-04-07T00:45:24.780Z"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"source": "/tmp/skill-selector-curated-3423638041",
|
"source": "/tmp/skill-selector-curated-3423638041",
|
||||||
"sourceType": "local",
|
"sourceType": "local",
|
||||||
"localPath": "/tmp/skill-selector-curated-3423638041/bun-development",
|
"localPath": "/tmp/skill-selector-curated-3423638041/bun-development",
|
||||||
"installedAt": "2026-04-07T00:45:24.781Z"
|
"installedAt": "2026-04-07T00:45:24.781Z"
|
||||||
}
|
}
|
||||||
|
|||||||
6
.claude/skills/create-agent/.openskills.json
Normal file
6
.claude/skills/create-agent/.openskills.json
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"source": "/tmp/skill-selector-curated-812268656",
|
||||||
|
"sourceType": "local",
|
||||||
|
"localPath": "/tmp/skill-selector-curated-812268656/create-agent",
|
||||||
|
"installedAt": "2026-04-07T03:45:37.970Z"
|
||||||
|
}
|
||||||
852
.claude/skills/create-agent/SKILL.md
Normal file
852
.claude/skills/create-agent/SKILL.md
Normal file
@@ -0,0 +1,852 @@
|
|||||||
|
---
|
||||||
|
name: create-agent
|
||||||
|
description: Bootstrap a modular AI agent with OpenRouter SDK, extensible hooks, and optional Ink TUI
|
||||||
|
metadata:
|
||||||
|
version: 0.0.0
|
||||||
|
homepage: https://openrouter.ai
|
||||||
|
---
|
||||||
|
|
||||||
|
# Build a Modular AI Agent with OpenRouter
|
||||||
|
|
||||||
|
This skill helps you create a **modular AI agent** with:
|
||||||
|
|
||||||
|
- **Standalone Agent Core** - Runs independently, extensible via hooks
|
||||||
|
- **OpenRouter SDK** - Unified access to 300+ language models
|
||||||
|
- **Optional Ink TUI** - Beautiful terminal UI (separate from agent logic)
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────────────┐
|
||||||
|
│ Your Application │
|
||||||
|
├─────────────────────────────────────────────────────┤
|
||||||
|
│ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │
|
||||||
|
│ │ Ink TUI │ │ HTTP API │ │ Discord │ │
|
||||||
|
│ └──────┬──────┘ └──────┬──────┘ └──────┬──────┘ │
|
||||||
|
│ │ │ │ │
|
||||||
|
│ └────────────────┼────────────────┘ │
|
||||||
|
│ ▼ │
|
||||||
|
│ ┌───────────────────────┐ │
|
||||||
|
│ │ Agent Core │ │
|
||||||
|
│ │ (hooks & lifecycle) │ │
|
||||||
|
│ └───────────┬───────────┘ │
|
||||||
|
│ ▼ │
|
||||||
|
│ ┌───────────────────────┐ │
|
||||||
|
│ │ OpenRouter SDK │ │
|
||||||
|
│ └───────────────────────┘ │
|
||||||
|
└─────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
|
|
||||||
|
Get an OpenRouter API key at: https://openrouter.ai/settings/keys
|
||||||
|
|
||||||
|
⚠️ **Security:** Never commit API keys. Use environment variables.
|
||||||
|
|
||||||
|
## Project Setup
|
||||||
|
|
||||||
|
### Step 1: Initialize Project
|
||||||
|
|
||||||
|
```bash
|
||||||
|
mkdir my-agent && cd my-agent
|
||||||
|
npm init -y
|
||||||
|
npm pkg set type="module"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step 2: Install Dependencies
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install @openrouter/sdk zod eventemitter3
|
||||||
|
npm install ink react # Optional: only for TUI
|
||||||
|
npm install -D typescript @types/react tsx
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step 3: Create tsconfig.json
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"module": "NodeNext",
|
||||||
|
"moduleResolution": "NodeNext",
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
"strict": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"outDir": "dist"
|
||||||
|
},
|
||||||
|
"include": ["src"]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step 4: Add Scripts to package.json
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"scripts": {
|
||||||
|
"start": "tsx src/cli.tsx",
|
||||||
|
"start:headless": "tsx src/headless.ts",
|
||||||
|
"dev": "tsx watch src/cli.tsx"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## File Structure
|
||||||
|
|
||||||
|
```bash
|
||||||
|
src/
|
||||||
|
├── agent.ts # Standalone agent core with hooks
|
||||||
|
├── tools.ts # Tool definitions
|
||||||
|
├── cli.tsx # Ink TUI (optional interface)
|
||||||
|
└── headless.ts # Headless usage example
|
||||||
|
```
|
||||||
|
|
||||||
|
## Step 1: Agent Core with Hooks
|
||||||
|
|
||||||
|
Create `src/agent.ts` - the standalone agent that can run anywhere:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { OpenRouter, tool, stepCountIs } from '@openrouter/sdk';
|
||||||
|
import type { Tool, StopCondition, StreamableOutputItem } from '@openrouter/sdk';
|
||||||
|
import { EventEmitter } from 'eventemitter3';
|
||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
// Message types
|
||||||
|
export interface Message {
|
||||||
|
role: 'user' | 'assistant' | 'system';
|
||||||
|
content: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Agent events for hooks (items-based streaming model)
|
||||||
|
export interface AgentEvents {
|
||||||
|
'message:user': (message: Message) => void;
|
||||||
|
'message:assistant': (message: Message) => void;
|
||||||
|
'item:update': (item: StreamableOutputItem) => void; // Items emitted with same ID, replace by ID
|
||||||
|
'stream:start': () => void;
|
||||||
|
'stream:delta': (delta: string, accumulated: string) => void;
|
||||||
|
'stream:end': (fullText: string) => void;
|
||||||
|
'tool:call': (name: string, args: unknown) => void;
|
||||||
|
'tool:result': (name: string, result: unknown) => void;
|
||||||
|
'reasoning:update': (text: string) => void; // Extended thinking content
|
||||||
|
'error': (error: Error) => void;
|
||||||
|
'thinking:start': () => void;
|
||||||
|
'thinking:end': () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// Agent configuration
|
||||||
|
export interface AgentConfig {
|
||||||
|
apiKey: string;
|
||||||
|
model?: string;
|
||||||
|
instructions?: string;
|
||||||
|
tools?: Tool<z.ZodTypeAny, z.ZodTypeAny>[];
|
||||||
|
maxSteps?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// The Agent class - runs independently of any UI
|
||||||
|
export class Agent extends EventEmitter<AgentEvents> {
|
||||||
|
private client: OpenRouter;
|
||||||
|
private messages: Message[] = [];
|
||||||
|
private config: Required<Omit<AgentConfig, 'apiKey'>> & { apiKey: string };
|
||||||
|
|
||||||
|
constructor(config: AgentConfig) {
|
||||||
|
super();
|
||||||
|
this.client = new OpenRouter({ apiKey: config.apiKey });
|
||||||
|
this.config = {
|
||||||
|
apiKey: config.apiKey,
|
||||||
|
model: config.model ?? 'openrouter/auto',
|
||||||
|
instructions: config.instructions ?? 'You are a helpful assistant.',
|
||||||
|
tools: config.tools ?? [],
|
||||||
|
maxSteps: config.maxSteps ?? 5,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get conversation history
|
||||||
|
getMessages(): Message[] {
|
||||||
|
return [...this.messages];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear conversation
|
||||||
|
clearHistory(): void {
|
||||||
|
this.messages = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add a system message
|
||||||
|
setInstructions(instructions: string): void {
|
||||||
|
this.config.instructions = instructions;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Register additional tools at runtime
|
||||||
|
addTool(newTool: Tool<z.ZodTypeAny, z.ZodTypeAny>): void {
|
||||||
|
this.config.tools.push(newTool);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Send a message and get streaming response using items-based model
|
||||||
|
// Items are emitted multiple times with the same ID but progressively updated content
|
||||||
|
// Replace items by their ID rather than accumulating chunks
|
||||||
|
async send(content: string): Promise<string> {
|
||||||
|
const userMessage: Message = { role: 'user', content };
|
||||||
|
this.messages.push(userMessage);
|
||||||
|
this.emit('message:user', userMessage);
|
||||||
|
this.emit('thinking:start');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = this.client.callModel({
|
||||||
|
model: this.config.model,
|
||||||
|
instructions: this.config.instructions,
|
||||||
|
input: this.messages.map((m) => ({ role: m.role, content: m.content })),
|
||||||
|
tools: this.config.tools.length > 0 ? this.config.tools : undefined,
|
||||||
|
stopWhen: [stepCountIs(this.config.maxSteps)],
|
||||||
|
});
|
||||||
|
|
||||||
|
this.emit('stream:start');
|
||||||
|
let fullText = '';
|
||||||
|
|
||||||
|
// Use getItemsStream() for items-based streaming (recommended)
|
||||||
|
// Each item emission is complete - replace by ID, don't accumulate
|
||||||
|
for await (const item of result.getItemsStream()) {
|
||||||
|
// Emit the item for UI state management (use Map keyed by item.id)
|
||||||
|
this.emit('item:update', item);
|
||||||
|
|
||||||
|
switch (item.type) {
|
||||||
|
case 'message':
|
||||||
|
// Message items contain progressively updated content
|
||||||
|
const textContent = item.content?.find((c: { type: string }) => c.type === 'output_text');
|
||||||
|
if (textContent && 'text' in textContent) {
|
||||||
|
const newText = textContent.text;
|
||||||
|
if (newText !== fullText) {
|
||||||
|
const delta = newText.slice(fullText.length);
|
||||||
|
fullText = newText;
|
||||||
|
this.emit('stream:delta', delta, fullText);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case 'function_call':
|
||||||
|
// Function call arguments stream progressively
|
||||||
|
if (item.status === 'completed') {
|
||||||
|
this.emit('tool:call', item.name, JSON.parse(item.arguments || '{}'));
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case 'function_call_output':
|
||||||
|
this.emit('tool:result', item.callId, item.output);
|
||||||
|
break;
|
||||||
|
case 'reasoning':
|
||||||
|
// Extended thinking/reasoning content
|
||||||
|
const reasoningText = item.content?.find((c: { type: string }) => c.type === 'reasoning_text');
|
||||||
|
if (reasoningText && 'text' in reasoningText) {
|
||||||
|
this.emit('reasoning:update', reasoningText.text);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
// Additional item types: web_search_call, file_search_call, image_generation_call
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get final text if streaming didn't capture it
|
||||||
|
if (!fullText) {
|
||||||
|
fullText = await result.getText();
|
||||||
|
}
|
||||||
|
|
||||||
|
this.emit('stream:end', fullText);
|
||||||
|
|
||||||
|
const assistantMessage: Message = { role: 'assistant', content: fullText };
|
||||||
|
this.messages.push(assistantMessage);
|
||||||
|
this.emit('message:assistant', assistantMessage);
|
||||||
|
|
||||||
|
return fullText;
|
||||||
|
} catch (err) {
|
||||||
|
const error = err instanceof Error ? err : new Error(String(err));
|
||||||
|
this.emit('error', error);
|
||||||
|
throw error;
|
||||||
|
} finally {
|
||||||
|
this.emit('thinking:end');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Send without streaming (simpler for programmatic use)
|
||||||
|
async sendSync(content: string): Promise<string> {
|
||||||
|
const userMessage: Message = { role: 'user', content };
|
||||||
|
this.messages.push(userMessage);
|
||||||
|
this.emit('message:user', userMessage);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = this.client.callModel({
|
||||||
|
model: this.config.model,
|
||||||
|
instructions: this.config.instructions,
|
||||||
|
input: this.messages.map((m) => ({ role: m.role, content: m.content })),
|
||||||
|
tools: this.config.tools.length > 0 ? this.config.tools : undefined,
|
||||||
|
stopWhen: [stepCountIs(this.config.maxSteps)],
|
||||||
|
});
|
||||||
|
|
||||||
|
const fullText = await result.getText();
|
||||||
|
const assistantMessage: Message = { role: 'assistant', content: fullText };
|
||||||
|
this.messages.push(assistantMessage);
|
||||||
|
this.emit('message:assistant', assistantMessage);
|
||||||
|
|
||||||
|
return fullText;
|
||||||
|
} catch (err) {
|
||||||
|
const error = err instanceof Error ? err : new Error(String(err));
|
||||||
|
this.emit('error', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Factory function for easy creation
|
||||||
|
export function createAgent(config: AgentConfig): Agent {
|
||||||
|
return new Agent(config);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Step 2: Define Tools
|
||||||
|
|
||||||
|
Create `src/tools.ts`:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { tool } from '@openrouter/sdk';
|
||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
export const timeTool = tool({
|
||||||
|
name: 'get_current_time',
|
||||||
|
description: 'Get the current date and time',
|
||||||
|
inputSchema: z.object({
|
||||||
|
timezone: z.string().optional().describe('Timezone (e.g., "UTC", "America/New_York")'),
|
||||||
|
}),
|
||||||
|
execute: async ({ timezone }) => {
|
||||||
|
return {
|
||||||
|
time: new Date().toLocaleString('en-US', { timeZone: timezone || 'UTC' }),
|
||||||
|
timezone: timezone || 'UTC',
|
||||||
|
};
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export const calculatorTool = tool({
|
||||||
|
name: 'calculate',
|
||||||
|
description: 'Perform mathematical calculations',
|
||||||
|
inputSchema: z.object({
|
||||||
|
expression: z.string().describe('Math expression (e.g., "2 + 2", "sqrt(16)")'),
|
||||||
|
}),
|
||||||
|
execute: async ({ expression }) => {
|
||||||
|
// Simple safe eval for basic math
|
||||||
|
const sanitized = expression.replace(/[^0-9+\-*/().\s]/g, '');
|
||||||
|
const result = Function(`"use strict"; return (${sanitized})`)();
|
||||||
|
return { expression, result };
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export const defaultTools = [timeTool, calculatorTool];
|
||||||
|
```
|
||||||
|
|
||||||
|
## Step 3: Headless Usage (No UI)
|
||||||
|
|
||||||
|
Create `src/headless.ts` - use the agent programmatically:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { createAgent } from './agent.js';
|
||||||
|
import { defaultTools } from './tools.js';
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const agent = createAgent({
|
||||||
|
apiKey: process.env.OPENROUTER_API_KEY!,
|
||||||
|
model: 'openrouter/auto',
|
||||||
|
instructions: 'You are a helpful assistant with access to tools.',
|
||||||
|
tools: defaultTools,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Hook into events
|
||||||
|
agent.on('thinking:start', () => console.log('\n🤔 Thinking...'));
|
||||||
|
agent.on('tool:call', (name, args) => console.log(`🔧 Using ${name}:`, args));
|
||||||
|
agent.on('stream:delta', (delta) => process.stdout.write(delta));
|
||||||
|
agent.on('stream:end', () => console.log('\n'));
|
||||||
|
agent.on('error', (err) => console.error('❌ Error:', err.message));
|
||||||
|
|
||||||
|
// Interactive loop
|
||||||
|
const readline = await import('readline');
|
||||||
|
const rl = readline.createInterface({
|
||||||
|
input: process.stdin,
|
||||||
|
output: process.stdout,
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log('Agent ready. Type your message (Ctrl+C to exit):\n');
|
||||||
|
|
||||||
|
const prompt = () => {
|
||||||
|
rl.question('You: ', async (input) => {
|
||||||
|
if (!input.trim()) {
|
||||||
|
prompt();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await agent.send(input);
|
||||||
|
prompt();
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
prompt();
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch(console.error);
|
||||||
|
```
|
||||||
|
|
||||||
|
Run headless: `OPENROUTER_API_KEY=sk-or-... npm run start:headless`
|
||||||
|
|
||||||
|
## Step 4: Ink TUI (Optional Interface)
|
||||||
|
|
||||||
|
Create `src/cli.tsx` - a beautiful terminal UI that uses the agent with items-based streaming:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import React, { useState, useEffect, useCallback } from 'react';
|
||||||
|
import { render, Box, Text, useInput, useApp } from 'ink';
|
||||||
|
import type { StreamableOutputItem } from '@openrouter/sdk';
|
||||||
|
import { createAgent, type Agent, type Message } from './agent.js';
|
||||||
|
import { defaultTools } from './tools.js';
|
||||||
|
|
||||||
|
// Initialize agent (runs independently of UI)
|
||||||
|
const agent = createAgent({
|
||||||
|
apiKey: process.env.OPENROUTER_API_KEY!,
|
||||||
|
model: 'openrouter/auto',
|
||||||
|
instructions: 'You are a helpful assistant. Be concise.',
|
||||||
|
tools: defaultTools,
|
||||||
|
});
|
||||||
|
|
||||||
|
function ChatMessage({ message }: { message: Message }) {
|
||||||
|
const isUser = message.role === 'user';
|
||||||
|
return (
|
||||||
|
<Box flexDirection="column" marginBottom={1}>
|
||||||
|
<Text bold color={isUser ? 'cyan' : 'green'}>
|
||||||
|
{isUser ? '▶ You' : '◀ Assistant'}
|
||||||
|
</Text>
|
||||||
|
<Text wrap="wrap">{message.content}</Text>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Render streaming items by type using the items-based pattern
|
||||||
|
function ItemRenderer({ item }: { item: StreamableOutputItem }) {
|
||||||
|
switch (item.type) {
|
||||||
|
case 'message': {
|
||||||
|
const textContent = item.content?.find((c: { type: string }) => c.type === 'output_text');
|
||||||
|
const text = textContent && 'text' in textContent ? textContent.text : '';
|
||||||
|
return (
|
||||||
|
<Box flexDirection="column" marginBottom={1}>
|
||||||
|
<Text bold color="green">◀ Assistant</Text>
|
||||||
|
<Text wrap="wrap">{text}</Text>
|
||||||
|
{item.status !== 'completed' && <Text color="gray">▌</Text>}
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
case 'function_call':
|
||||||
|
return (
|
||||||
|
<Text color="yellow">
|
||||||
|
{item.status === 'completed' ? ' ✓' : ' 🔧'} {item.name}
|
||||||
|
{item.status === 'in_progress' && '...'}
|
||||||
|
</Text>
|
||||||
|
);
|
||||||
|
case 'reasoning': {
|
||||||
|
const reasoningText = item.content?.find((c: { type: string }) => c.type === 'reasoning_text');
|
||||||
|
const text = reasoningText && 'text' in reasoningText ? reasoningText.text : '';
|
||||||
|
return (
|
||||||
|
<Box flexDirection="column" marginBottom={1}>
|
||||||
|
<Text bold color="magenta">💭 Thinking</Text>
|
||||||
|
<Text wrap="wrap" color="gray">{text}</Text>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function InputField({
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
onSubmit,
|
||||||
|
disabled,
|
||||||
|
}: {
|
||||||
|
value: string;
|
||||||
|
onChange: (v: string) => void;
|
||||||
|
onSubmit: () => void;
|
||||||
|
disabled: boolean;
|
||||||
|
}) {
|
||||||
|
useInput((input, key) => {
|
||||||
|
if (disabled) return;
|
||||||
|
if (key.return) onSubmit();
|
||||||
|
else if (key.backspace || key.delete) onChange(value.slice(0, -1));
|
||||||
|
else if (input && !key.ctrl && !key.meta) onChange(value + input);
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box>
|
||||||
|
<Text color="yellow">{'> '}</Text>
|
||||||
|
<Text>{value}</Text>
|
||||||
|
<Text color="gray">{disabled ? ' ···' : '█'}</Text>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function App() {
|
||||||
|
const { exit } = useApp();
|
||||||
|
const [messages, setMessages] = useState<Message[]>([]);
|
||||||
|
const [input, setInput] = useState('');
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
// Use Map keyed by item ID for efficient React state updates (items-based pattern)
|
||||||
|
const [items, setItems] = useState<Map<string, StreamableOutputItem>>(new Map());
|
||||||
|
|
||||||
|
useInput((_, key) => {
|
||||||
|
if (key.escape) exit();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Subscribe to agent events using items-based streaming
|
||||||
|
useEffect(() => {
|
||||||
|
const onThinkingStart = () => {
|
||||||
|
setIsLoading(true);
|
||||||
|
setItems(new Map()); // Clear items for new response
|
||||||
|
};
|
||||||
|
|
||||||
|
// Items-based streaming: replace items by ID, don't accumulate
|
||||||
|
const onItemUpdate = (item: StreamableOutputItem) => {
|
||||||
|
setItems((prev) => new Map(prev).set(item.id, item));
|
||||||
|
};
|
||||||
|
|
||||||
|
const onMessageAssistant = () => {
|
||||||
|
setMessages(agent.getMessages());
|
||||||
|
setItems(new Map()); // Clear streaming items
|
||||||
|
setIsLoading(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const onError = (err: Error) => {
|
||||||
|
setIsLoading(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
agent.on('thinking:start', onThinkingStart);
|
||||||
|
agent.on('item:update', onItemUpdate);
|
||||||
|
agent.on('message:assistant', onMessageAssistant);
|
||||||
|
agent.on('error', onError);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
agent.off('thinking:start', onThinkingStart);
|
||||||
|
agent.off('item:update', onItemUpdate);
|
||||||
|
agent.off('message:assistant', onMessageAssistant);
|
||||||
|
agent.off('error', onError);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const sendMessage = useCallback(async () => {
|
||||||
|
if (!input.trim() || isLoading) return;
|
||||||
|
const text = input.trim();
|
||||||
|
setInput('');
|
||||||
|
setMessages((prev) => [...prev, { role: 'user', content: text }]);
|
||||||
|
await agent.send(text);
|
||||||
|
}, [input, isLoading]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box flexDirection="column" padding={1}>
|
||||||
|
<Box marginBottom={1}>
|
||||||
|
<Text bold color="magenta">🤖 OpenRouter Agent</Text>
|
||||||
|
<Text color="gray"> (Esc to exit)</Text>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Box flexDirection="column" marginBottom={1}>
|
||||||
|
{/* Render completed messages */}
|
||||||
|
{messages.map((msg, i) => (
|
||||||
|
<ChatMessage key={i} message={msg} />
|
||||||
|
))}
|
||||||
|
|
||||||
|
{/* Render streaming items by type (items-based pattern) */}
|
||||||
|
{Array.from(items.values()).map((item) => (
|
||||||
|
<ItemRenderer key={item.id} item={item} />
|
||||||
|
))}
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Box borderStyle="single" borderColor="gray" paddingX={1}>
|
||||||
|
<InputField
|
||||||
|
value={input}
|
||||||
|
onChange={setInput}
|
||||||
|
onSubmit={sendMessage}
|
||||||
|
disabled={isLoading}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
render(<App />);
|
||||||
|
```
|
||||||
|
|
||||||
|
Run TUI: `OPENROUTER_API_KEY=sk-or-... npm start`
|
||||||
|
|
||||||
|
## Understanding Items-Based Streaming
|
||||||
|
|
||||||
|
The OpenRouter SDK uses an **items-based streaming model** - a key paradigm where items are emitted multiple times with the same ID but progressively updated content. Instead of accumulating chunks, you **replace items by their ID**.
|
||||||
|
|
||||||
|
### How It Works
|
||||||
|
|
||||||
|
Each iteration of `getItemsStream()` yields a complete item with updated content:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Iteration 1: Partial message
|
||||||
|
{ id: "msg_123", type: "message", content: [{ type: "output_text", text: "Hello" }] }
|
||||||
|
|
||||||
|
// Iteration 2: Updated message (replace, don't append)
|
||||||
|
{ id: "msg_123", type: "message", content: [{ type: "output_text", text: "Hello world" }] }
|
||||||
|
```
|
||||||
|
|
||||||
|
For function calls, arguments stream progressively:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Iteration 1: Partial arguments
|
||||||
|
{ id: "call_456", type: "function_call", name: "get_weather", arguments: "{\"q" }
|
||||||
|
|
||||||
|
// Iteration 2: Complete arguments
|
||||||
|
{ id: "call_456", type: "function_call", name: "get_weather", arguments: "{\"query\": \"Paris\"}", status: "completed" }
|
||||||
|
```
|
||||||
|
|
||||||
|
### Why Items Are Better
|
||||||
|
|
||||||
|
**Traditional (accumulation required):**
|
||||||
|
```typescript
|
||||||
|
let text = '';
|
||||||
|
for await (const chunk of result.getTextStream()) {
|
||||||
|
text += chunk; // Manual accumulation
|
||||||
|
updateUI(text);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Items (complete replacement):**
|
||||||
|
```typescript
|
||||||
|
const items = new Map<string, StreamableOutputItem>();
|
||||||
|
for await (const item of result.getItemsStream()) {
|
||||||
|
items.set(item.id, item); // Replace by ID
|
||||||
|
updateUI(items);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Benefits:
|
||||||
|
- **No manual chunk management** - each item is complete
|
||||||
|
- **Handles concurrent outputs** - function calls and messages can stream in parallel
|
||||||
|
- **Full TypeScript inference** for all item types
|
||||||
|
- **Natural Map-based state** works perfectly with React/UI frameworks
|
||||||
|
|
||||||
|
## Extending the Agent
|
||||||
|
|
||||||
|
### Add Custom Hooks
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const agent = createAgent({ apiKey: '...' });
|
||||||
|
|
||||||
|
// Log all events
|
||||||
|
agent.on('message:user', (msg) => {
|
||||||
|
saveToDatabase('user', msg.content);
|
||||||
|
});
|
||||||
|
|
||||||
|
agent.on('message:assistant', (msg) => {
|
||||||
|
saveToDatabase('assistant', msg.content);
|
||||||
|
sendWebhook('new_message', msg);
|
||||||
|
});
|
||||||
|
|
||||||
|
agent.on('tool:call', (name, args) => {
|
||||||
|
analytics.track('tool_used', { name, args });
|
||||||
|
});
|
||||||
|
|
||||||
|
agent.on('error', (err) => {
|
||||||
|
errorReporting.capture(err);
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### Use with HTTP Server
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import express from 'express';
|
||||||
|
import { createAgent } from './agent.js';
|
||||||
|
|
||||||
|
const app = express();
|
||||||
|
app.use(express.json());
|
||||||
|
|
||||||
|
// One agent per session (store in memory or Redis)
|
||||||
|
const sessions = new Map<string, Agent>();
|
||||||
|
|
||||||
|
app.post('/chat', async (req, res) => {
|
||||||
|
const { sessionId, message } = req.body;
|
||||||
|
|
||||||
|
let agent = sessions.get(sessionId);
|
||||||
|
if (!agent) {
|
||||||
|
agent = createAgent({ apiKey: process.env.OPENROUTER_API_KEY! });
|
||||||
|
sessions.set(sessionId, agent);
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await agent.sendSync(message);
|
||||||
|
res.json({ response, history: agent.getMessages() });
|
||||||
|
});
|
||||||
|
|
||||||
|
app.listen(3000);
|
||||||
|
```
|
||||||
|
|
||||||
|
### Use with Discord
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { Client, GatewayIntentBits } from 'discord.js';
|
||||||
|
import { createAgent } from './agent.js';
|
||||||
|
|
||||||
|
const discord = new Client({
|
||||||
|
intents: [GatewayIntentBits.Guilds, GatewayIntentBits.GuildMessages],
|
||||||
|
});
|
||||||
|
|
||||||
|
const agents = new Map<string, Agent>();
|
||||||
|
|
||||||
|
discord.on('messageCreate', async (msg) => {
|
||||||
|
if (msg.author.bot) return;
|
||||||
|
|
||||||
|
let agent = agents.get(msg.channelId);
|
||||||
|
if (!agent) {
|
||||||
|
agent = createAgent({ apiKey: process.env.OPENROUTER_API_KEY! });
|
||||||
|
agents.set(msg.channelId, agent);
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await agent.sendSync(msg.content);
|
||||||
|
await msg.reply(response);
|
||||||
|
});
|
||||||
|
|
||||||
|
discord.login(process.env.DISCORD_TOKEN);
|
||||||
|
```
|
||||||
|
|
||||||
|
## Agent API Reference
|
||||||
|
|
||||||
|
### Constructor Options
|
||||||
|
|
||||||
|
| Option | Type | Default | Description |
|
||||||
|
|--------|------|---------|-------------|
|
||||||
|
| apiKey | string | required | OpenRouter API key |
|
||||||
|
| model | string | 'openrouter/auto' | Model to use |
|
||||||
|
| instructions | string | 'You are a helpful assistant.' | System prompt |
|
||||||
|
| tools | Tool[] | [] | Available tools |
|
||||||
|
| maxSteps | number | 5 | Max agentic loop iterations |
|
||||||
|
|
||||||
|
### Methods
|
||||||
|
|
||||||
|
| Method | Returns | Description |
|
||||||
|
|--------|---------|-------------|
|
||||||
|
| `send(content)` | Promise<string> | Send message with streaming |
|
||||||
|
| `sendSync(content)` | Promise<string> | Send message without streaming |
|
||||||
|
| `getMessages()` | Message[] | Get conversation history |
|
||||||
|
| `clearHistory()` | void | Clear conversation |
|
||||||
|
| `setInstructions(text)` | void | Update system prompt |
|
||||||
|
| `addTool(tool)` | void | Add tool at runtime |
|
||||||
|
|
||||||
|
### Events
|
||||||
|
|
||||||
|
| Event | Payload | Description |
|
||||||
|
|-------|---------|-------------|
|
||||||
|
| `message:user` | Message | User message added |
|
||||||
|
| `message:assistant` | Message | Assistant response complete |
|
||||||
|
| `item:update` | StreamableOutputItem | Item emitted (replace by ID, don't accumulate) |
|
||||||
|
| `stream:start` | - | Streaming started |
|
||||||
|
| `stream:delta` | (delta, accumulated) | New text chunk |
|
||||||
|
| `stream:end` | fullText | Streaming complete |
|
||||||
|
| `tool:call` | (name, args) | Tool being called |
|
||||||
|
| `tool:result` | (name, result) | Tool returned result |
|
||||||
|
| `reasoning:update` | text | Extended thinking content |
|
||||||
|
| `thinking:start` | - | Agent processing |
|
||||||
|
| `thinking:end` | - | Agent done processing |
|
||||||
|
| `error` | Error | Error occurred |
|
||||||
|
|
||||||
|
### Item Types (from getItemsStream)
|
||||||
|
|
||||||
|
The SDK uses an items-based streaming model where items are emitted multiple times with the same ID but progressively updated content. Replace items by their ID rather than accumulating chunks.
|
||||||
|
|
||||||
|
| Type | Purpose |
|
||||||
|
|------|---------|
|
||||||
|
| `message` | Assistant text responses |
|
||||||
|
| `function_call` | Tool invocations with streaming arguments |
|
||||||
|
| `function_call_output` | Results from executed tools |
|
||||||
|
| `reasoning` | Extended thinking content |
|
||||||
|
| `web_search_call` | Web search operations |
|
||||||
|
| `file_search_call` | File search operations |
|
||||||
|
| `image_generation_call` | Image generation operations |
|
||||||
|
|
||||||
|
## Discovering Models
|
||||||
|
|
||||||
|
**Do not hardcode model IDs** - they change frequently. Use the models API:
|
||||||
|
|
||||||
|
### Fetch Available Models
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
interface OpenRouterModel {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
description?: string;
|
||||||
|
context_length: number;
|
||||||
|
pricing: { prompt: string; completion: string };
|
||||||
|
top_provider?: { is_moderated: boolean };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchModels(): Promise<OpenRouterModel[]> {
|
||||||
|
const res = await fetch('https://openrouter.ai/api/v1/models');
|
||||||
|
const data = await res.json();
|
||||||
|
return data.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find models by criteria
|
||||||
|
async function findModels(filter: {
|
||||||
|
author?: string; // e.g., 'anthropic', 'openai', 'google'
|
||||||
|
minContext?: number; // e.g., 100000 for 100k context
|
||||||
|
maxPromptPrice?: number; // e.g., 0.001 for cheap models
|
||||||
|
}): Promise<OpenRouterModel[]> {
|
||||||
|
const models = await fetchModels();
|
||||||
|
|
||||||
|
return models.filter((m) => {
|
||||||
|
if (filter.author && !m.id.startsWith(filter.author + '/')) return false;
|
||||||
|
if (filter.minContext && m.context_length < filter.minContext) return false;
|
||||||
|
if (filter.maxPromptPrice) {
|
||||||
|
const price = parseFloat(m.pricing.prompt);
|
||||||
|
if (price > filter.maxPromptPrice) return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Example: Get latest Claude models
|
||||||
|
const claudeModels = await findModels({ author: 'anthropic' });
|
||||||
|
console.log(claudeModels.map((m) => m.id));
|
||||||
|
|
||||||
|
// Example: Get models with 100k+ context
|
||||||
|
const longContextModels = await findModels({ minContext: 100000 });
|
||||||
|
|
||||||
|
// Example: Get cheap models
|
||||||
|
const cheapModels = await findModels({ maxPromptPrice: 0.0005 });
|
||||||
|
```
|
||||||
|
|
||||||
|
### Dynamic Model Selection in Agent
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Create agent with dynamic model selection
|
||||||
|
const models = await fetchModels();
|
||||||
|
const bestModel = models.find((m) => m.id.includes('claude')) || models[0];
|
||||||
|
|
||||||
|
const agent = createAgent({
|
||||||
|
apiKey: process.env.OPENROUTER_API_KEY!,
|
||||||
|
model: bestModel.id, // Use discovered model
|
||||||
|
instructions: 'You are a helpful assistant.',
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### Using openrouter/auto
|
||||||
|
|
||||||
|
For simplicity, use `openrouter/auto` which automatically selects the best
|
||||||
|
available model for your request:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const agent = createAgent({
|
||||||
|
apiKey: process.env.OPENROUTER_API_KEY!,
|
||||||
|
model: 'openrouter/auto', // Auto-selects best model
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### Models API Reference
|
||||||
|
|
||||||
|
- **Endpoint**: `GET https://openrouter.ai/api/v1/models`
|
||||||
|
- **Response**: `{ data: OpenRouterModel[] }`
|
||||||
|
- **Browse models**: https://openrouter.ai/models
|
||||||
|
|
||||||
|
## Resources
|
||||||
|
|
||||||
|
- OpenRouter Docs: https://openrouter.ai/docs
|
||||||
|
- Models API: https://openrouter.ai/api/v1/models
|
||||||
|
- Ink Docs: https://github.com/vadimdemedes/ink
|
||||||
|
- Get API Key: https://openrouter.ai/settings/keys
|
||||||
13
.claude/skills/create-agent/metadata.json
Normal file
13
.claude/skills/create-agent/metadata.json
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
{
|
||||||
|
"version": "1.1.0",
|
||||||
|
"organization": "OpenRouter Inc",
|
||||||
|
"date": "January 2026",
|
||||||
|
"abstract": "Complete guide for building modular AI agents with the OpenRouter TypeScript SDK. Features a standalone Agent class with EventEmitter-based hooks for extensibility, items-based streaming model for efficient UI state management, optional Ink TUI for interactive terminal interfaces, and examples for HTTP server and Discord integrations. Includes Zod-based tool definitions, streaming responses with support for reasoning/thinking items, multi-turn conversations, and dynamic model discovery via the OpenRouter Models API.",
|
||||||
|
"references": [
|
||||||
|
"https://openrouter.ai/docs/sdks/typescript",
|
||||||
|
"https://openrouter.ai/docs/sdks/typescript/call-model/working-with-items",
|
||||||
|
"https://openrouter.ai/docs/api-reference",
|
||||||
|
"https://openrouter.ai/api/v1/models",
|
||||||
|
"https://github.com/vadimdemedes/ink"
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"source": "/tmp/skill-selector-curated-3423638041",
|
"source": "/tmp/skill-selector-curated-3423638041",
|
||||||
"sourceType": "local",
|
"sourceType": "local",
|
||||||
"localPath": "/tmp/skill-selector-curated-3423638041/drizzle-orm-expert",
|
"localPath": "/tmp/skill-selector-curated-3423638041/drizzle-orm-expert",
|
||||||
"installedAt": "2026-04-07T00:45:24.781Z"
|
"installedAt": "2026-04-07T00:45:24.781Z"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"source": "/tmp/skill-selector-curated-3423638041",
|
"source": "/tmp/skill-selector-curated-3423638041",
|
||||||
"sourceType": "local",
|
"sourceType": "local",
|
||||||
"localPath": "/tmp/skill-selector-curated-3423638041/grill-me",
|
"localPath": "/tmp/skill-selector-curated-3423638041/grill-me",
|
||||||
"installedAt": "2026-04-07T00:45:24.781Z"
|
"installedAt": "2026-04-07T00:45:24.781Z"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"source": "/tmp/skill-selector-curated-3423638041",
|
"source": "/tmp/skill-selector-curated-3423638041",
|
||||||
"sourceType": "local",
|
"sourceType": "local",
|
||||||
"localPath": "/tmp/skill-selector-curated-3423638041/nextjs-app-router-patterns",
|
"localPath": "/tmp/skill-selector-curated-3423638041/nextjs-app-router-patterns",
|
||||||
"installedAt": "2026-04-07T00:45:24.782Z"
|
"installedAt": "2026-04-07T00:45:24.782Z"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"source": "/tmp/skill-selector-curated-3423638041",
|
"source": "/tmp/skill-selector-curated-3423638041",
|
||||||
"sourceType": "local",
|
"sourceType": "local",
|
||||||
"localPath": "/tmp/skill-selector-curated-3423638041/nextjs-best-practices",
|
"localPath": "/tmp/skill-selector-curated-3423638041/nextjs-best-practices",
|
||||||
"installedAt": "2026-04-07T00:45:24.782Z"
|
"installedAt": "2026-04-07T00:45:24.782Z"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"source": "/tmp/skill-selector-curated-3423638041",
|
"source": "/tmp/skill-selector-curated-3423638041",
|
||||||
"sourceType": "local",
|
"sourceType": "local",
|
||||||
"localPath": "/tmp/skill-selector-curated-3423638041/nextjs-developer",
|
"localPath": "/tmp/skill-selector-curated-3423638041/nextjs-developer",
|
||||||
"installedAt": "2026-04-07T00:45:24.782Z"
|
"installedAt": "2026-04-07T00:45:24.782Z"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"source": "/tmp/skill-selector-curated-3423638041",
|
"source": "/tmp/skill-selector-curated-3423638041",
|
||||||
"sourceType": "local",
|
"sourceType": "local",
|
||||||
"localPath": "/tmp/skill-selector-curated-3423638041/react-nextjs-development",
|
"localPath": "/tmp/skill-selector-curated-3423638041/react-nextjs-development",
|
||||||
"installedAt": "2026-04-07T00:45:24.783Z"
|
"installedAt": "2026-04-07T00:45:24.783Z"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"source": "/tmp/skill-selector-curated-3423638041",
|
"source": "/tmp/skill-selector-curated-3423638041",
|
||||||
"sourceType": "local",
|
"sourceType": "local",
|
||||||
"localPath": "/tmp/skill-selector-curated-3423638041/tdd",
|
"localPath": "/tmp/skill-selector-curated-3423638041/tdd",
|
||||||
"installedAt": "2026-04-07T00:45:24.783Z"
|
"installedAt": "2026-04-07T00:45:24.783Z"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"source": "/tmp/skill-selector-curated-3423638041",
|
"source": "/tmp/skill-selector-curated-3423638041",
|
||||||
"sourceType": "local",
|
"sourceType": "local",
|
||||||
"localPath": "/tmp/skill-selector-curated-3423638041/typescript-advanced-types",
|
"localPath": "/tmp/skill-selector-curated-3423638041/typescript-advanced-types",
|
||||||
"installedAt": "2026-04-07T00:45:24.783Z"
|
"installedAt": "2026-04-07T00:45:24.783Z"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"source": "/tmp/skill-selector-curated-3423638041",
|
"source": "/tmp/skill-selector-curated-3423638041",
|
||||||
"sourceType": "local",
|
"sourceType": "local",
|
||||||
"localPath": "/tmp/skill-selector-curated-3423638041/typescript-expert",
|
"localPath": "/tmp/skill-selector-curated-3423638041/typescript-expert",
|
||||||
"installedAt": "2026-04-07T00:45:24.784Z"
|
"installedAt": "2026-04-07T00:45:24.784Z"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
/**
|
/**
|
||||||
* TypeScript Utility Types Library
|
* TypeScript Utility Types Library
|
||||||
*
|
*
|
||||||
* A collection of commonly used utility types for TypeScript projects.
|
* A collection of commonly used utility types for TypeScript projects.
|
||||||
* Copy and use as needed in your projects.
|
* Copy and use as needed in your projects.
|
||||||
*/
|
*/
|
||||||
@@ -11,19 +11,19 @@
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Create nominal/branded types to prevent primitive obsession.
|
* Create nominal/branded types to prevent primitive obsession.
|
||||||
*
|
*
|
||||||
* @example
|
* @example
|
||||||
* type UserId = Brand<string, 'UserId'>
|
* type UserId = Brand<string, 'UserId'>
|
||||||
* type OrderId = Brand<string, 'OrderId'>
|
* type OrderId = Brand<string, 'OrderId'>
|
||||||
*/
|
*/
|
||||||
export type Brand<K, T> = K & { readonly __brand: T }
|
export type Brand<K, T> = K & { readonly __brand: T };
|
||||||
|
|
||||||
// Branded type constructors
|
// Branded type constructors
|
||||||
export type UserId = Brand<string, 'UserId'>
|
export type UserId = Brand<string, "UserId">;
|
||||||
export type Email = Brand<string, 'Email'>
|
export type Email = Brand<string, "Email">;
|
||||||
export type UUID = Brand<string, 'UUID'>
|
export type UUID = Brand<string, "UUID">;
|
||||||
export type Timestamp = Brand<number, 'Timestamp'>
|
export type Timestamp = Brand<number, "Timestamp">;
|
||||||
export type PositiveNumber = Brand<number, 'PositiveNumber'>
|
export type PositiveNumber = Brand<number, "PositiveNumber">;
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
// RESULT TYPE (Error Handling)
|
// RESULT TYPE (Error Handling)
|
||||||
@@ -33,33 +33,33 @@ export type PositiveNumber = Brand<number, 'PositiveNumber'>
|
|||||||
* Type-safe error handling without exceptions.
|
* Type-safe error handling without exceptions.
|
||||||
*/
|
*/
|
||||||
export type Result<T, E = Error> =
|
export type Result<T, E = Error> =
|
||||||
| { success: true; data: T }
|
| { success: true; data: T }
|
||||||
| { success: false; error: E }
|
| { success: false; error: E };
|
||||||
|
|
||||||
export const ok = <T>(data: T): Result<T, never> => ({
|
export const ok = <T>(data: T): Result<T, never> => ({
|
||||||
success: true,
|
success: true,
|
||||||
data
|
data,
|
||||||
})
|
});
|
||||||
|
|
||||||
export const err = <E>(error: E): Result<never, E> => ({
|
export const err = <E>(error: E): Result<never, E> => ({
|
||||||
success: false,
|
success: false,
|
||||||
error
|
error,
|
||||||
})
|
});
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
// OPTION TYPE (Nullable Handling)
|
// OPTION TYPE (Nullable Handling)
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Explicit optional value handling.
|
* Explicit optional value handling.
|
||||||
*/
|
*/
|
||||||
export type Option<T> = Some<T> | None
|
export type Option<T> = Some<T> | None;
|
||||||
|
|
||||||
export type Some<T> = { type: 'some'; value: T }
|
export type Some<T> = { type: "some"; value: T };
|
||||||
export type None = { type: 'none' }
|
export type None = { type: "none" };
|
||||||
|
|
||||||
export const some = <T>(value: T): Some<T> => ({ type: 'some', value })
|
export const some = <T>(value: T): Some<T> => ({ type: "some", value });
|
||||||
export const none: None = { type: 'none' }
|
export const none: None = { type: "none" };
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
// DEEP UTILITIES
|
// DEEP UTILITIES
|
||||||
@@ -69,31 +69,31 @@ export const none: None = { type: 'none' }
|
|||||||
* Make all properties deeply readonly.
|
* Make all properties deeply readonly.
|
||||||
*/
|
*/
|
||||||
export type DeepReadonly<T> = T extends (...args: any[]) => any
|
export type DeepReadonly<T> = T extends (...args: any[]) => any
|
||||||
? T
|
? T
|
||||||
: T extends object
|
: T extends object
|
||||||
? { readonly [K in keyof T]: DeepReadonly<T[K]> }
|
? { readonly [K in keyof T]: DeepReadonly<T[K]> }
|
||||||
: T
|
: T;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Make all properties deeply optional.
|
* Make all properties deeply optional.
|
||||||
*/
|
*/
|
||||||
export type DeepPartial<T> = T extends object
|
export type DeepPartial<T> = T extends object
|
||||||
? { [K in keyof T]?: DeepPartial<T[K]> }
|
? { [K in keyof T]?: DeepPartial<T[K]> }
|
||||||
: T
|
: T;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Make all properties deeply required.
|
* Make all properties deeply required.
|
||||||
*/
|
*/
|
||||||
export type DeepRequired<T> = T extends object
|
export type DeepRequired<T> = T extends object
|
||||||
? { [K in keyof T]-?: DeepRequired<T[K]> }
|
? { [K in keyof T]-?: DeepRequired<T[K]> }
|
||||||
: T
|
: T;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Make all properties deeply mutable (remove readonly).
|
* Make all properties deeply mutable (remove readonly).
|
||||||
*/
|
*/
|
||||||
export type DeepMutable<T> = T extends object
|
export type DeepMutable<T> = T extends object
|
||||||
? { -readonly [K in keyof T]: DeepMutable<T[K]> }
|
? { -readonly [K in keyof T]: DeepMutable<T[K]> }
|
||||||
: T
|
: T;
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
// OBJECT UTILITIES
|
// OBJECT UTILITIES
|
||||||
@@ -103,38 +103,40 @@ export type DeepMutable<T> = T extends object
|
|||||||
* Get keys of object where value matches type.
|
* Get keys of object where value matches type.
|
||||||
*/
|
*/
|
||||||
export type KeysOfType<T, V> = {
|
export type KeysOfType<T, V> = {
|
||||||
[K in keyof T]: T[K] extends V ? K : never
|
[K in keyof T]: T[K] extends V ? K : never;
|
||||||
}[keyof T]
|
}[keyof T];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Pick properties by value type.
|
* Pick properties by value type.
|
||||||
*/
|
*/
|
||||||
export type PickByType<T, V> = Pick<T, KeysOfType<T, V>>
|
export type PickByType<T, V> = Pick<T, KeysOfType<T, V>>;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Omit properties by value type.
|
* Omit properties by value type.
|
||||||
*/
|
*/
|
||||||
export type OmitByType<T, V> = Omit<T, KeysOfType<T, V>>
|
export type OmitByType<T, V> = Omit<T, KeysOfType<T, V>>;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Make specific keys optional.
|
* Make specific keys optional.
|
||||||
*/
|
*/
|
||||||
export type PartialBy<T, K extends keyof T> = Omit<T, K> & Partial<Pick<T, K>>
|
export type PartialBy<T, K extends keyof T> = Omit<T, K> & Partial<Pick<T, K>>;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Make specific keys required.
|
* Make specific keys required.
|
||||||
*/
|
*/
|
||||||
export type RequiredBy<T, K extends keyof T> = Omit<T, K> & Required<Pick<T, K>>
|
export type RequiredBy<T, K extends keyof T> = Omit<T, K> &
|
||||||
|
Required<Pick<T, K>>;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Make specific keys readonly.
|
* Make specific keys readonly.
|
||||||
*/
|
*/
|
||||||
export type ReadonlyBy<T, K extends keyof T> = Omit<T, K> & Readonly<Pick<T, K>>
|
export type ReadonlyBy<T, K extends keyof T> = Omit<T, K> &
|
||||||
|
Readonly<Pick<T, K>>;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Merge two types (second overrides first).
|
* Merge two types (second overrides first).
|
||||||
*/
|
*/
|
||||||
export type Merge<T, U> = Omit<T, keyof U> & U
|
export type Merge<T, U> = Omit<T, keyof U> & U;
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
// ARRAY UTILITIES
|
// ARRAY UTILITIES
|
||||||
@@ -143,30 +145,30 @@ export type Merge<T, U> = Omit<T, keyof U> & U
|
|||||||
/**
|
/**
|
||||||
* Get element type from array.
|
* Get element type from array.
|
||||||
*/
|
*/
|
||||||
export type ElementOf<T> = T extends (infer E)[] ? E : never
|
export type ElementOf<T> = T extends (infer E)[] ? E : never;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Tuple of specific length.
|
* Tuple of specific length.
|
||||||
*/
|
*/
|
||||||
export type Tuple<T, N extends number> = N extends N
|
export type Tuple<T, N extends number> = N extends N
|
||||||
? number extends N
|
? number extends N
|
||||||
? T[]
|
? T[]
|
||||||
: _TupleOf<T, N, []>
|
: _TupleOf<T, N, []>
|
||||||
: never
|
: never;
|
||||||
|
|
||||||
type _TupleOf<T, N extends number, R extends unknown[]> = R['length'] extends N
|
type _TupleOf<T, N extends number, R extends unknown[]> = R["length"] extends N
|
||||||
? R
|
? R
|
||||||
: _TupleOf<T, N, [T, ...R]>
|
: _TupleOf<T, N, [T, ...R]>;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Non-empty array.
|
* Non-empty array.
|
||||||
*/
|
*/
|
||||||
export type NonEmptyArray<T> = [T, ...T[]]
|
export type NonEmptyArray<T> = [T, ...T[]];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* At least N elements.
|
* At least N elements.
|
||||||
*/
|
*/
|
||||||
export type AtLeast<T, N extends number> = [...Tuple<T, N>, ...T[]]
|
export type AtLeast<T, N extends number> = [...Tuple<T, N>, ...T[]];
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
// FUNCTION UTILITIES
|
// FUNCTION UTILITIES
|
||||||
@@ -175,28 +177,28 @@ export type AtLeast<T, N extends number> = [...Tuple<T, N>, ...T[]]
|
|||||||
/**
|
/**
|
||||||
* Get function arguments as tuple.
|
* Get function arguments as tuple.
|
||||||
*/
|
*/
|
||||||
export type Arguments<T> = T extends (...args: infer A) => any ? A : never
|
export type Arguments<T> = T extends (...args: infer A) => any ? A : never;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get first argument of function.
|
* Get first argument of function.
|
||||||
*/
|
*/
|
||||||
export type FirstArgument<T> = T extends (first: infer F, ...args: any[]) => any
|
export type FirstArgument<T> = T extends (first: infer F, ...args: any[]) => any
|
||||||
? F
|
? F
|
||||||
: never
|
: never;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Async version of function.
|
* Async version of function.
|
||||||
*/
|
*/
|
||||||
export type AsyncFunction<T extends (...args: any[]) => any> = (
|
export type AsyncFunction<T extends (...args: any[]) => any> = (
|
||||||
...args: Parameters<T>
|
...args: Parameters<T>
|
||||||
) => Promise<Awaited<ReturnType<T>>>
|
) => Promise<Awaited<ReturnType<T>>>;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Promisify return type.
|
* Promisify return type.
|
||||||
*/
|
*/
|
||||||
export type Promisify<T> = T extends (...args: infer A) => infer R
|
export type Promisify<T> = T extends (...args: infer A) => infer R
|
||||||
? (...args: A) => Promise<Awaited<R>>
|
? (...args: A) => Promise<Awaited<R>>
|
||||||
: never
|
: never;
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
// STRING UTILITIES
|
// STRING UTILITIES
|
||||||
@@ -205,31 +207,30 @@ export type Promisify<T> = T extends (...args: infer A) => infer R
|
|||||||
/**
|
/**
|
||||||
* Split string by delimiter.
|
* Split string by delimiter.
|
||||||
*/
|
*/
|
||||||
export type Split<S extends string, D extends string> =
|
export type Split<
|
||||||
S extends `${infer T}${D}${infer U}`
|
S extends string,
|
||||||
? [T, ...Split<U, D>]
|
D extends string,
|
||||||
: [S]
|
> = S extends `${infer T}${D}${infer U}` ? [T, ...Split<U, D>] : [S];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Join tuple to string.
|
* Join tuple to string.
|
||||||
*/
|
*/
|
||||||
export type Join<T extends string[], D extends string> =
|
export type Join<T extends string[], D extends string> = T extends []
|
||||||
T extends []
|
? ""
|
||||||
? ''
|
: T extends [infer F extends string]
|
||||||
: T extends [infer F extends string]
|
? F
|
||||||
? F
|
: T extends [infer F extends string, ...infer R extends string[]]
|
||||||
: T extends [infer F extends string, ...infer R extends string[]]
|
? `${F}${D}${Join<R, D>}`
|
||||||
? `${F}${D}${Join<R, D>}`
|
: never;
|
||||||
: never
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Path to nested object.
|
* Path to nested object.
|
||||||
*/
|
*/
|
||||||
export type PathOf<T, K extends keyof T = keyof T> = K extends string
|
export type PathOf<T, K extends keyof T = keyof T> = K extends string
|
||||||
? T[K] extends object
|
? T[K] extends object
|
||||||
? K | `${K}.${PathOf<T[K]>}`
|
? K | `${K}.${PathOf<T[K]>}`
|
||||||
: K
|
: K
|
||||||
: never
|
: never;
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
// UNION UTILITIES
|
// UNION UTILITIES
|
||||||
@@ -238,27 +239,28 @@ export type PathOf<T, K extends keyof T = keyof T> = K extends string
|
|||||||
/**
|
/**
|
||||||
* Last element of union.
|
* Last element of union.
|
||||||
*/
|
*/
|
||||||
export type UnionLast<T> = UnionToIntersection<
|
export type UnionLast<T> =
|
||||||
T extends any ? () => T : never
|
UnionToIntersection<T extends any ? () => T : never> extends () => infer R
|
||||||
> extends () => infer R
|
? R
|
||||||
? R
|
: never;
|
||||||
: never
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Union to intersection.
|
* Union to intersection.
|
||||||
*/
|
*/
|
||||||
export type UnionToIntersection<U> = (
|
export type UnionToIntersection<U> = (
|
||||||
U extends any ? (k: U) => void : never
|
U extends any
|
||||||
|
? (k: U) => void
|
||||||
|
: never
|
||||||
) extends (k: infer I) => void
|
) extends (k: infer I) => void
|
||||||
? I
|
? I
|
||||||
: never
|
: never;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Union to tuple.
|
* Union to tuple.
|
||||||
*/
|
*/
|
||||||
export type UnionToTuple<T, L = UnionLast<T>> = [T] extends [never]
|
export type UnionToTuple<T, L = UnionLast<T>> = [T] extends [never]
|
||||||
? []
|
? []
|
||||||
: [...UnionToTuple<Exclude<T, L>>, L]
|
: [...UnionToTuple<Exclude<T, L>>, L];
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
// VALIDATION UTILITIES
|
// VALIDATION UTILITIES
|
||||||
@@ -268,28 +270,25 @@ export type UnionToTuple<T, L = UnionLast<T>> = [T] extends [never]
|
|||||||
* Assert type at compile time.
|
* Assert type at compile time.
|
||||||
*/
|
*/
|
||||||
export type AssertEqual<T, U> =
|
export type AssertEqual<T, U> =
|
||||||
(<V>() => V extends T ? 1 : 2) extends (<V>() => V extends U ? 1 : 2)
|
(<V>() => V extends T ? 1 : 2) extends <V>() => V extends U ? 1 : 2
|
||||||
? true
|
? true
|
||||||
: false
|
: false;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Ensure type is not never.
|
* Ensure type is not never.
|
||||||
*/
|
*/
|
||||||
export type IsNever<T> = [T] extends [never] ? true : false
|
export type IsNever<T> = [T] extends [never] ? true : false;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Ensure type is any.
|
* Ensure type is any.
|
||||||
*/
|
*/
|
||||||
export type IsAny<T> = 0 extends 1 & T ? true : false
|
export type IsAny<T> = 0 extends 1 & T ? true : false;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Ensure type is unknown.
|
* Ensure type is unknown.
|
||||||
*/
|
*/
|
||||||
export type IsUnknown<T> = IsAny<T> extends true
|
export type IsUnknown<T> =
|
||||||
? false
|
IsAny<T> extends true ? false : unknown extends T ? true : false;
|
||||||
: unknown extends T
|
|
||||||
? true
|
|
||||||
: false
|
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
// JSON UTILITIES
|
// JSON UTILITIES
|
||||||
@@ -298,23 +297,23 @@ export type IsUnknown<T> = IsAny<T> extends true
|
|||||||
/**
|
/**
|
||||||
* JSON-safe types.
|
* JSON-safe types.
|
||||||
*/
|
*/
|
||||||
export type JsonPrimitive = string | number | boolean | null
|
export type JsonPrimitive = string | number | boolean | null;
|
||||||
export type JsonArray = JsonValue[]
|
export type JsonArray = JsonValue[];
|
||||||
export type JsonObject = { [key: string]: JsonValue }
|
export type JsonObject = { [key: string]: JsonValue };
|
||||||
export type JsonValue = JsonPrimitive | JsonArray | JsonObject
|
export type JsonValue = JsonPrimitive | JsonArray | JsonObject;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Make type JSON-serializable.
|
* Make type JSON-serializable.
|
||||||
*/
|
*/
|
||||||
export type Jsonify<T> = T extends JsonPrimitive
|
export type Jsonify<T> = T extends JsonPrimitive
|
||||||
? T
|
? T
|
||||||
: T extends undefined | ((...args: any[]) => any) | symbol
|
: T extends undefined | ((...args: any[]) => any) | symbol
|
||||||
? never
|
? never
|
||||||
: T extends { toJSON(): infer R }
|
: T extends { toJSON(): infer R }
|
||||||
? R
|
? R
|
||||||
: T extends object
|
: T extends object
|
||||||
? { [K in keyof T]: Jsonify<T[K]> }
|
? { [K in keyof T]: Jsonify<T[K]> }
|
||||||
: never
|
: never;
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
// EXHAUSTIVE CHECK
|
// EXHAUSTIVE CHECK
|
||||||
@@ -324,12 +323,12 @@ export type Jsonify<T> = T extends JsonPrimitive
|
|||||||
* Ensure all cases are handled in switch/if.
|
* Ensure all cases are handled in switch/if.
|
||||||
*/
|
*/
|
||||||
export function assertNever(value: never, message?: string): never {
|
export function assertNever(value: never, message?: string): never {
|
||||||
throw new Error(message ?? `Unexpected value: ${value}`)
|
throw new Error(message ?? `Unexpected value: ${value}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Exhaustive check without throwing.
|
* Exhaustive check without throwing.
|
||||||
*/
|
*/
|
||||||
export function exhaustiveCheck(_value: never): void {
|
export function exhaustiveCheck(_value: never): void {
|
||||||
// This function should never be called
|
// This function should never be called
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"source": "/tmp/skill-selector-curated-3423638041",
|
"source": "/tmp/skill-selector-curated-3423638041",
|
||||||
"sourceType": "local",
|
"sourceType": "local",
|
||||||
"localPath": "/tmp/skill-selector-curated-3423638041/typescript-pro",
|
"localPath": "/tmp/skill-selector-curated-3423638041/typescript-pro",
|
||||||
"installedAt": "2026-04-07T00:45:24.785Z"
|
"installedAt": "2026-04-07T00:45:24.785Z"
|
||||||
}
|
}
|
||||||
|
|||||||
6
.claude/skills/typescript-sdk/.openskills.json
Normal file
6
.claude/skills/typescript-sdk/.openskills.json
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"source": "/tmp/skill-selector-curated-812268656",
|
||||||
|
"sourceType": "local",
|
||||||
|
"localPath": "/tmp/skill-selector-curated-812268656/typescript-sdk",
|
||||||
|
"installedAt": "2026-04-07T03:45:37.971Z"
|
||||||
|
}
|
||||||
1249
.claude/skills/typescript-sdk/SKILL.md
Normal file
1249
.claude/skills/typescript-sdk/SKILL.md
Normal file
File diff suppressed because it is too large
Load Diff
13
.claude/skills/typescript-sdk/metadata.json
Normal file
13
.claude/skills/typescript-sdk/metadata.json
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
{
|
||||||
|
"version": "1.0.0",
|
||||||
|
"organization": "OpenRouter Inc",
|
||||||
|
"date": "January 2026",
|
||||||
|
"abstract": "Complete reference for the OpenRouter TypeScript SDK, enabling AI agents to integrate with 300+ AI models through a unified, type-safe interface. Covers the callModel pattern for text generation, streaming, and multi-turn conversations with automatic tool execution. Includes detailed TypeScript interfaces for Responses API message shapes, streaming event types, authentication methods (API key and OAuth PKCE flow), Zod-based tool definitions, stop conditions, and format conversion helpers. Designed for AI agent consumption with production-ready examples and best practices.",
|
||||||
|
"references": [
|
||||||
|
"https://openrouter.ai/docs/sdks/typescript/call-model/overview",
|
||||||
|
"https://openrouter.ai/docs/sdks",
|
||||||
|
"https://openrouter.ai/docs/api/reference/overview",
|
||||||
|
"https://openrouter.ai/docs/sdks/typescript/api-reference/api-reference/oauth",
|
||||||
|
"https://openrouter.ai/docs/sdks/llms-full.txt"
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -20,4 +20,5 @@ repos:
|
|||||||
- sickn33/antigravity-awesome-skills
|
- sickn33/antigravity-awesome-skills
|
||||||
- tfriedel/claude-office-skills
|
- tfriedel/claude-office-skills
|
||||||
- wshobson/agents
|
- wshobson/agents
|
||||||
|
- OpenRouterTeam/agent-skills
|
||||||
- ~/projects/ai-skills
|
- ~/projects/ai-skills
|
||||||
|
|||||||
3
bun.lock
3
bun.lock
@@ -5,6 +5,7 @@
|
|||||||
"": {
|
"": {
|
||||||
"name": "ical-pwa",
|
"name": "ical-pwa",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@openrouter/sdk": "^0.11.2",
|
||||||
"@radix-ui/react-checkbox": "^1.3.3",
|
"@radix-ui/react-checkbox": "^1.3.3",
|
||||||
"@radix-ui/react-dialog": "^1.1.15",
|
"@radix-ui/react-dialog": "^1.1.15",
|
||||||
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
||||||
@@ -266,6 +267,8 @@
|
|||||||
|
|
||||||
"@nolyfill/is-core-module": ["@nolyfill/is-core-module@1.0.39", "", {}, "sha512-nn5ozdjYQpUCZlWGuxcJY/KpxkWQs4DcbMCmKojjyrYDEAGy4Ce19NN4v5MduafTwJlbKc99UA8YhSVqq9yPZA=="],
|
"@nolyfill/is-core-module": ["@nolyfill/is-core-module@1.0.39", "", {}, "sha512-nn5ozdjYQpUCZlWGuxcJY/KpxkWQs4DcbMCmKojjyrYDEAGy4Ce19NN4v5MduafTwJlbKc99UA8YhSVqq9yPZA=="],
|
||||||
|
|
||||||
|
"@openrouter/sdk": ["@openrouter/sdk@0.11.2", "", { "dependencies": { "zod": "^3.25.0 || ^4.0.0" } }, "sha512-uu8zu7vd4hA2l4vUD1UiZuefxqaH2/ixFcUG8GIO9+qcEJkWX4AYAil7SpGmZOTgy8STLFTEk4M4MmyUW0YMLg=="],
|
||||||
|
|
||||||
"@opentelemetry/api": ["@opentelemetry/api@1.9.1", "", {}, "sha512-gLyJlPHPZYdAk1JENA9LeHejZe1Ti77/pTeFm/nMXmQH/HFZlcS/O2XJB+L8fkbrNSqhdtlvjBVjxwUYanNH5Q=="],
|
"@opentelemetry/api": ["@opentelemetry/api@1.9.1", "", {}, "sha512-gLyJlPHPZYdAk1JENA9LeHejZe1Ti77/pTeFm/nMXmQH/HFZlcS/O2XJB+L8fkbrNSqhdtlvjBVjxwUYanNH5Q=="],
|
||||||
|
|
||||||
"@opentelemetry/semantic-conventions": ["@opentelemetry/semantic-conventions@1.40.0", "", {}, "sha512-cifvXDhcqMwwTlTK04GBNeIe7yyo28Mfby85QXFe1Yk8nmi36Ab/5UQwptOx84SsoGNRg+EVSjwzfSZMy6pmlw=="],
|
"@opentelemetry/semantic-conventions": ["@opentelemetry/semantic-conventions@1.40.0", "", {}, "sha512-cifvXDhcqMwwTlTK04GBNeIe7yyo28Mfby85QXFe1Yk8nmi36Ab/5UQwptOx84SsoGNRg+EVSjwzfSZMy6pmlw=="],
|
||||||
|
|||||||
@@ -1,21 +1,21 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://ui.shadcn.com/schema.json",
|
"$schema": "https://ui.shadcn.com/schema.json",
|
||||||
"style": "new-york",
|
"style": "new-york",
|
||||||
"rsc": true,
|
"rsc": true,
|
||||||
"tsx": true,
|
"tsx": true,
|
||||||
"tailwind": {
|
"tailwind": {
|
||||||
"config": "",
|
"config": "",
|
||||||
"css": "src/app/globals.css",
|
"css": "src/app/globals.css",
|
||||||
"baseColor": "neutral",
|
"baseColor": "neutral",
|
||||||
"cssVariables": true,
|
"cssVariables": true,
|
||||||
"prefix": ""
|
"prefix": ""
|
||||||
},
|
},
|
||||||
"aliases": {
|
"aliases": {
|
||||||
"components": "@/components",
|
"components": "@/components",
|
||||||
"utils": "@/lib/utils",
|
"utils": "@/lib/utils",
|
||||||
"ui": "@/components/ui",
|
"ui": "@/components/ui",
|
||||||
"lib": "@/lib",
|
"lib": "@/lib",
|
||||||
"hooks": "@/hooks"
|
"hooks": "@/hooks"
|
||||||
},
|
},
|
||||||
"iconLibrary": "lucide"
|
"iconLibrary": "lucide"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,21 +1,21 @@
|
|||||||
import { defineConfig } from 'drizzle-kit';
|
import { defineConfig } from "drizzle-kit";
|
||||||
import * as dotenv from 'dotenv';
|
import * as dotenv from "dotenv";
|
||||||
|
|
||||||
if (!process.env.DATABASE_URL) {
|
if (!process.env.DATABASE_URL) {
|
||||||
if (process.env.NODE_ENV === "production") {
|
if (process.env.NODE_ENV === "production") {
|
||||||
dotenv.config({ path: '.env.production' });
|
dotenv.config({ path: ".env.production" });
|
||||||
} else {
|
} else {
|
||||||
dotenv.config({ path: '.env.local' });
|
dotenv.config({ path: ".env.local" });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
dialect: 'postgresql',
|
dialect: "postgresql",
|
||||||
schema: './src/db/schema.ts',
|
schema: "./src/db/schema.ts",
|
||||||
out: './drizzle',
|
out: "./drizzle",
|
||||||
dbCredentials: {
|
dbCredentials: {
|
||||||
url: process.env.DATABASE_URL!,
|
url: process.env.DATABASE_URL!,
|
||||||
},
|
},
|
||||||
verbose: true,
|
verbose: true,
|
||||||
strict: true,
|
strict: true,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,344 +1,321 @@
|
|||||||
{
|
{
|
||||||
"id": "00000000-0000-0000-0000-000000000000",
|
"id": "00000000-0000-0000-0000-000000000000",
|
||||||
"prevId": "",
|
"prevId": "",
|
||||||
"version": "7",
|
"version": "7",
|
||||||
"dialect": "postgresql",
|
"dialect": "postgresql",
|
||||||
"tables": {
|
"tables": {
|
||||||
"public.session": {
|
"public.session": {
|
||||||
"name": "session",
|
"name": "session",
|
||||||
"schema": "",
|
"schema": "",
|
||||||
"columns": {
|
"columns": {
|
||||||
"sessionToken": {
|
"sessionToken": {
|
||||||
"name": "sessionToken",
|
"name": "sessionToken",
|
||||||
"type": "text",
|
"type": "text",
|
||||||
"primaryKey": true,
|
"primaryKey": true,
|
||||||
"notNull": true
|
"notNull": true
|
||||||
},
|
},
|
||||||
"userId": {
|
"userId": {
|
||||||
"name": "userId",
|
"name": "userId",
|
||||||
"type": "text",
|
"type": "text",
|
||||||
"primaryKey": false,
|
"primaryKey": false,
|
||||||
"notNull": true
|
"notNull": true
|
||||||
},
|
},
|
||||||
"expires": {
|
"expires": {
|
||||||
"name": "expires",
|
"name": "expires",
|
||||||
"type": "timestamp",
|
"type": "timestamp",
|
||||||
"primaryKey": false,
|
"primaryKey": false,
|
||||||
"notNull": true
|
"notNull": true
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"indexes": {},
|
"indexes": {},
|
||||||
"foreignKeys": {
|
"foreignKeys": {
|
||||||
"session_userId_user_id_fk": {
|
"session_userId_user_id_fk": {
|
||||||
"name": "session_userId_user_id_fk",
|
"name": "session_userId_user_id_fk",
|
||||||
"tableFrom": "session",
|
"tableFrom": "session",
|
||||||
"tableTo": "user",
|
"tableTo": "user",
|
||||||
"schemaTo": "public",
|
"schemaTo": "public",
|
||||||
"columnsFrom": [
|
"columnsFrom": ["userId"],
|
||||||
"userId"
|
"columnsTo": ["id"],
|
||||||
],
|
"onDelete": "cascade",
|
||||||
"columnsTo": [
|
"onUpdate": "no action"
|
||||||
"id"
|
}
|
||||||
],
|
},
|
||||||
"onDelete": "cascade",
|
"compositePrimaryKeys": {},
|
||||||
"onUpdate": "no action"
|
"uniqueConstraints": {},
|
||||||
}
|
"checkConstraints": {},
|
||||||
},
|
"policies": {},
|
||||||
"compositePrimaryKeys": {},
|
"isRLSEnabled": false
|
||||||
"uniqueConstraints": {},
|
},
|
||||||
"checkConstraints": {},
|
"public.user": {
|
||||||
"policies": {},
|
"name": "user",
|
||||||
"isRLSEnabled": false
|
"schema": "",
|
||||||
},
|
"columns": {
|
||||||
"public.user": {
|
"id": {
|
||||||
"name": "user",
|
"name": "id",
|
||||||
"schema": "",
|
"type": "text",
|
||||||
"columns": {
|
"primaryKey": true,
|
||||||
"id": {
|
"notNull": true
|
||||||
"name": "id",
|
},
|
||||||
"type": "text",
|
"name": {
|
||||||
"primaryKey": true,
|
"name": "name",
|
||||||
"notNull": true
|
"type": "text",
|
||||||
},
|
"primaryKey": false,
|
||||||
"name": {
|
"notNull": false
|
||||||
"name": "name",
|
},
|
||||||
"type": "text",
|
"email": {
|
||||||
"primaryKey": false,
|
"name": "email",
|
||||||
"notNull": false
|
"type": "text",
|
||||||
},
|
"primaryKey": false,
|
||||||
"email": {
|
"notNull": true
|
||||||
"name": "email",
|
},
|
||||||
"type": "text",
|
"emailVerified": {
|
||||||
"primaryKey": false,
|
"name": "emailVerified",
|
||||||
"notNull": true
|
"type": "timestamp",
|
||||||
},
|
"primaryKey": false,
|
||||||
"emailVerified": {
|
"notNull": false
|
||||||
"name": "emailVerified",
|
},
|
||||||
"type": "timestamp",
|
"image": {
|
||||||
"primaryKey": false,
|
"name": "image",
|
||||||
"notNull": false
|
"type": "text",
|
||||||
},
|
"primaryKey": false,
|
||||||
"image": {
|
"notNull": false
|
||||||
"name": "image",
|
}
|
||||||
"type": "text",
|
},
|
||||||
"primaryKey": false,
|
"indexes": {},
|
||||||
"notNull": false
|
"foreignKeys": {},
|
||||||
}
|
"compositePrimaryKeys": {},
|
||||||
},
|
"uniqueConstraints": {},
|
||||||
"indexes": {},
|
"checkConstraints": {},
|
||||||
"foreignKeys": {},
|
"policies": {},
|
||||||
"compositePrimaryKeys": {},
|
"isRLSEnabled": false
|
||||||
"uniqueConstraints": {},
|
},
|
||||||
"checkConstraints": {},
|
"public.verificationToken": {
|
||||||
"policies": {},
|
"name": "verificationToken",
|
||||||
"isRLSEnabled": false
|
"schema": "",
|
||||||
},
|
"columns": {
|
||||||
"public.verificationToken": {
|
"identifier": {
|
||||||
"name": "verificationToken",
|
"name": "identifier",
|
||||||
"schema": "",
|
"type": "text",
|
||||||
"columns": {
|
"primaryKey": false,
|
||||||
"identifier": {
|
"notNull": true
|
||||||
"name": "identifier",
|
},
|
||||||
"type": "text",
|
"token": {
|
||||||
"primaryKey": false,
|
"name": "token",
|
||||||
"notNull": true
|
"type": "text",
|
||||||
},
|
"primaryKey": false,
|
||||||
"token": {
|
"notNull": true
|
||||||
"name": "token",
|
},
|
||||||
"type": "text",
|
"expires": {
|
||||||
"primaryKey": false,
|
"name": "expires",
|
||||||
"notNull": true
|
"type": "timestamp",
|
||||||
},
|
"primaryKey": false,
|
||||||
"expires": {
|
"notNull": true
|
||||||
"name": "expires",
|
}
|
||||||
"type": "timestamp",
|
},
|
||||||
"primaryKey": false,
|
"indexes": {},
|
||||||
"notNull": true
|
"foreignKeys": {},
|
||||||
}
|
"compositePrimaryKeys": {
|
||||||
},
|
"verificationToken_identifier_token_pk": {
|
||||||
"indexes": {},
|
"name": "verificationToken_identifier_token_pk",
|
||||||
"foreignKeys": {},
|
"columns": ["identifier", "token"]
|
||||||
"compositePrimaryKeys": {
|
}
|
||||||
"verificationToken_identifier_token_pk": {
|
},
|
||||||
"name": "verificationToken_identifier_token_pk",
|
"uniqueConstraints": {},
|
||||||
"columns": [
|
"checkConstraints": {},
|
||||||
"identifier",
|
"policies": {},
|
||||||
"token"
|
"isRLSEnabled": false
|
||||||
]
|
},
|
||||||
}
|
"public.authenticator": {
|
||||||
},
|
"name": "authenticator",
|
||||||
"uniqueConstraints": {},
|
"schema": "",
|
||||||
"checkConstraints": {},
|
"columns": {
|
||||||
"policies": {},
|
"credentialID": {
|
||||||
"isRLSEnabled": false
|
"name": "credentialID",
|
||||||
},
|
"type": "text",
|
||||||
"public.authenticator": {
|
"primaryKey": false,
|
||||||
"name": "authenticator",
|
"notNull": true
|
||||||
"schema": "",
|
},
|
||||||
"columns": {
|
"userId": {
|
||||||
"credentialID": {
|
"name": "userId",
|
||||||
"name": "credentialID",
|
"type": "text",
|
||||||
"type": "text",
|
"primaryKey": false,
|
||||||
"primaryKey": false,
|
"notNull": true
|
||||||
"notNull": true
|
},
|
||||||
},
|
"providerAccountId": {
|
||||||
"userId": {
|
"name": "providerAccountId",
|
||||||
"name": "userId",
|
"type": "text",
|
||||||
"type": "text",
|
"primaryKey": false,
|
||||||
"primaryKey": false,
|
"notNull": true
|
||||||
"notNull": true
|
},
|
||||||
},
|
"credentialPublicKey": {
|
||||||
"providerAccountId": {
|
"name": "credentialPublicKey",
|
||||||
"name": "providerAccountId",
|
"type": "text",
|
||||||
"type": "text",
|
"primaryKey": false,
|
||||||
"primaryKey": false,
|
"notNull": true
|
||||||
"notNull": true
|
},
|
||||||
},
|
"counter": {
|
||||||
"credentialPublicKey": {
|
"name": "counter",
|
||||||
"name": "credentialPublicKey",
|
"type": "integer",
|
||||||
"type": "text",
|
"primaryKey": false,
|
||||||
"primaryKey": false,
|
"notNull": true
|
||||||
"notNull": true
|
},
|
||||||
},
|
"credentialDeviceType": {
|
||||||
"counter": {
|
"name": "credentialDeviceType",
|
||||||
"name": "counter",
|
"type": "text",
|
||||||
"type": "integer",
|
"primaryKey": false,
|
||||||
"primaryKey": false,
|
"notNull": true
|
||||||
"notNull": true
|
},
|
||||||
},
|
"credentialBackedUp": {
|
||||||
"credentialDeviceType": {
|
"name": "credentialBackedUp",
|
||||||
"name": "credentialDeviceType",
|
"type": "boolean",
|
||||||
"type": "text",
|
"primaryKey": false,
|
||||||
"primaryKey": false,
|
"notNull": true
|
||||||
"notNull": true
|
},
|
||||||
},
|
"transports": {
|
||||||
"credentialBackedUp": {
|
"name": "transports",
|
||||||
"name": "credentialBackedUp",
|
"type": "text",
|
||||||
"type": "boolean",
|
"primaryKey": false,
|
||||||
"primaryKey": false,
|
"notNull": false
|
||||||
"notNull": true
|
}
|
||||||
},
|
},
|
||||||
"transports": {
|
"indexes": {},
|
||||||
"name": "transports",
|
"foreignKeys": {
|
||||||
"type": "text",
|
"authenticator_userId_user_id_fk": {
|
||||||
"primaryKey": false,
|
"name": "authenticator_userId_user_id_fk",
|
||||||
"notNull": false
|
"tableFrom": "authenticator",
|
||||||
}
|
"tableTo": "user",
|
||||||
},
|
"schemaTo": "public",
|
||||||
"indexes": {},
|
"columnsFrom": ["userId"],
|
||||||
"foreignKeys": {
|
"columnsTo": ["id"],
|
||||||
"authenticator_userId_user_id_fk": {
|
"onDelete": "cascade",
|
||||||
"name": "authenticator_userId_user_id_fk",
|
"onUpdate": "no action"
|
||||||
"tableFrom": "authenticator",
|
}
|
||||||
"tableTo": "user",
|
},
|
||||||
"schemaTo": "public",
|
"compositePrimaryKeys": {
|
||||||
"columnsFrom": [
|
"authenticator_userId_credentialID_pk": {
|
||||||
"userId"
|
"name": "authenticator_userId_credentialID_pk",
|
||||||
],
|
"columns": ["credentialID", "userId"]
|
||||||
"columnsTo": [
|
}
|
||||||
"id"
|
},
|
||||||
],
|
"uniqueConstraints": {
|
||||||
"onDelete": "cascade",
|
"authenticator_credentialID_unique": {
|
||||||
"onUpdate": "no action"
|
"columns": ["credentialID"],
|
||||||
}
|
"nullsNotDistinct": false,
|
||||||
},
|
"name": "authenticator_credentialID_unique"
|
||||||
"compositePrimaryKeys": {
|
}
|
||||||
"authenticator_userId_credentialID_pk": {
|
},
|
||||||
"name": "authenticator_userId_credentialID_pk",
|
"checkConstraints": {},
|
||||||
"columns": [
|
"policies": {},
|
||||||
"credentialID",
|
"isRLSEnabled": false
|
||||||
"userId"
|
},
|
||||||
]
|
"public.account": {
|
||||||
}
|
"name": "account",
|
||||||
},
|
"schema": "",
|
||||||
"uniqueConstraints": {
|
"columns": {
|
||||||
"authenticator_credentialID_unique": {
|
"userId": {
|
||||||
"columns": [
|
"name": "userId",
|
||||||
"credentialID"
|
"type": "text",
|
||||||
],
|
"primaryKey": false,
|
||||||
"nullsNotDistinct": false,
|
"notNull": true
|
||||||
"name": "authenticator_credentialID_unique"
|
},
|
||||||
}
|
"type": {
|
||||||
},
|
"name": "type",
|
||||||
"checkConstraints": {},
|
"type": "text",
|
||||||
"policies": {},
|
"primaryKey": false,
|
||||||
"isRLSEnabled": false
|
"notNull": true
|
||||||
},
|
},
|
||||||
"public.account": {
|
"provider": {
|
||||||
"name": "account",
|
"name": "provider",
|
||||||
"schema": "",
|
"type": "text",
|
||||||
"columns": {
|
"primaryKey": false,
|
||||||
"userId": {
|
"notNull": true
|
||||||
"name": "userId",
|
},
|
||||||
"type": "text",
|
"providerAccountId": {
|
||||||
"primaryKey": false,
|
"name": "providerAccountId",
|
||||||
"notNull": true
|
"type": "text",
|
||||||
},
|
"primaryKey": false,
|
||||||
"type": {
|
"notNull": true
|
||||||
"name": "type",
|
},
|
||||||
"type": "text",
|
"refresh_token": {
|
||||||
"primaryKey": false,
|
"name": "refresh_token",
|
||||||
"notNull": true
|
"type": "text",
|
||||||
},
|
"primaryKey": false,
|
||||||
"provider": {
|
"notNull": false
|
||||||
"name": "provider",
|
},
|
||||||
"type": "text",
|
"access_token": {
|
||||||
"primaryKey": false,
|
"name": "access_token",
|
||||||
"notNull": true
|
"type": "text",
|
||||||
},
|
"primaryKey": false,
|
||||||
"providerAccountId": {
|
"notNull": false
|
||||||
"name": "providerAccountId",
|
},
|
||||||
"type": "text",
|
"expires_at": {
|
||||||
"primaryKey": false,
|
"name": "expires_at",
|
||||||
"notNull": true
|
"type": "text",
|
||||||
},
|
"primaryKey": false,
|
||||||
"refresh_token": {
|
"notNull": false
|
||||||
"name": "refresh_token",
|
},
|
||||||
"type": "text",
|
"token_type": {
|
||||||
"primaryKey": false,
|
"name": "token_type",
|
||||||
"notNull": false
|
"type": "text",
|
||||||
},
|
"primaryKey": false,
|
||||||
"access_token": {
|
"notNull": false
|
||||||
"name": "access_token",
|
},
|
||||||
"type": "text",
|
"scope": {
|
||||||
"primaryKey": false,
|
"name": "scope",
|
||||||
"notNull": false
|
"type": "text",
|
||||||
},
|
"primaryKey": false,
|
||||||
"expires_at": {
|
"notNull": false
|
||||||
"name": "expires_at",
|
},
|
||||||
"type": "text",
|
"id_token": {
|
||||||
"primaryKey": false,
|
"name": "id_token",
|
||||||
"notNull": false
|
"type": "text",
|
||||||
},
|
"primaryKey": false,
|
||||||
"token_type": {
|
"notNull": false
|
||||||
"name": "token_type",
|
},
|
||||||
"type": "text",
|
"session_state": {
|
||||||
"primaryKey": false,
|
"name": "session_state",
|
||||||
"notNull": false
|
"type": "text",
|
||||||
},
|
"primaryKey": false,
|
||||||
"scope": {
|
"notNull": false
|
||||||
"name": "scope",
|
}
|
||||||
"type": "text",
|
},
|
||||||
"primaryKey": false,
|
"indexes": {},
|
||||||
"notNull": false
|
"foreignKeys": {
|
||||||
},
|
"account_userId_user_id_fk": {
|
||||||
"id_token": {
|
"name": "account_userId_user_id_fk",
|
||||||
"name": "id_token",
|
"tableFrom": "account",
|
||||||
"type": "text",
|
"tableTo": "user",
|
||||||
"primaryKey": false,
|
"schemaTo": "public",
|
||||||
"notNull": false
|
"columnsFrom": ["userId"],
|
||||||
},
|
"columnsTo": ["id"],
|
||||||
"session_state": {
|
"onDelete": "cascade",
|
||||||
"name": "session_state",
|
"onUpdate": "no action"
|
||||||
"type": "text",
|
}
|
||||||
"primaryKey": false,
|
},
|
||||||
"notNull": false
|
"compositePrimaryKeys": {
|
||||||
}
|
"account_provider_providerAccountId_pk": {
|
||||||
},
|
"name": "account_provider_providerAccountId_pk",
|
||||||
"indexes": {},
|
"columns": ["provider", "providerAccountId"]
|
||||||
"foreignKeys": {
|
}
|
||||||
"account_userId_user_id_fk": {
|
},
|
||||||
"name": "account_userId_user_id_fk",
|
"uniqueConstraints": {},
|
||||||
"tableFrom": "account",
|
"checkConstraints": {},
|
||||||
"tableTo": "user",
|
"policies": {},
|
||||||
"schemaTo": "public",
|
"isRLSEnabled": false
|
||||||
"columnsFrom": [
|
}
|
||||||
"userId"
|
},
|
||||||
],
|
"enums": {},
|
||||||
"columnsTo": [
|
"schemas": {},
|
||||||
"id"
|
"sequences": {},
|
||||||
],
|
"roles": {},
|
||||||
"onDelete": "cascade",
|
"policies": {},
|
||||||
"onUpdate": "no action"
|
"views": {},
|
||||||
}
|
"_meta": {
|
||||||
},
|
"schemas": {},
|
||||||
"compositePrimaryKeys": {
|
"tables": {},
|
||||||
"account_provider_providerAccountId_pk": {
|
"columns": {}
|
||||||
"name": "account_provider_providerAccountId_pk",
|
},
|
||||||
"columns": [
|
"internal": {
|
||||||
"provider",
|
"tables": {}
|
||||||
"providerAccountId"
|
}
|
||||||
]
|
}
|
||||||
}
|
|
||||||
},
|
|
||||||
"uniqueConstraints": {},
|
|
||||||
"checkConstraints": {},
|
|
||||||
"policies": {},
|
|
||||||
"isRLSEnabled": false
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"enums": {},
|
|
||||||
"schemas": {},
|
|
||||||
"sequences": {},
|
|
||||||
"roles": {},
|
|
||||||
"policies": {},
|
|
||||||
"views": {},
|
|
||||||
"_meta": {
|
|
||||||
"schemas": {},
|
|
||||||
"tables": {},
|
|
||||||
"columns": {}
|
|
||||||
},
|
|
||||||
"internal": {
|
|
||||||
"tables": {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,328 +1,316 @@
|
|||||||
{
|
{
|
||||||
"id": "69e7666b-0b8c-4658-906d-993870a0b539",
|
"id": "69e7666b-0b8c-4658-906d-993870a0b539",
|
||||||
"prevId": "00000000-0000-0000-0000-000000000000",
|
"prevId": "00000000-0000-0000-0000-000000000000",
|
||||||
"version": "7",
|
"version": "7",
|
||||||
"dialect": "postgresql",
|
"dialect": "postgresql",
|
||||||
"tables": {
|
"tables": {
|
||||||
"public.account": {
|
"public.account": {
|
||||||
"name": "account",
|
"name": "account",
|
||||||
"schema": "",
|
"schema": "",
|
||||||
"columns": {
|
"columns": {
|
||||||
"id": {
|
"id": {
|
||||||
"name": "id",
|
"name": "id",
|
||||||
"type": "text",
|
"type": "text",
|
||||||
"primaryKey": true,
|
"primaryKey": true,
|
||||||
"notNull": true
|
"notNull": true
|
||||||
},
|
},
|
||||||
"accountId": {
|
"accountId": {
|
||||||
"name": "accountId",
|
"name": "accountId",
|
||||||
"type": "text",
|
"type": "text",
|
||||||
"primaryKey": false,
|
"primaryKey": false,
|
||||||
"notNull": true
|
"notNull": true
|
||||||
},
|
},
|
||||||
"providerId": {
|
"providerId": {
|
||||||
"name": "providerId",
|
"name": "providerId",
|
||||||
"type": "text",
|
"type": "text",
|
||||||
"primaryKey": false,
|
"primaryKey": false,
|
||||||
"notNull": true
|
"notNull": true
|
||||||
},
|
},
|
||||||
"userId": {
|
"userId": {
|
||||||
"name": "userId",
|
"name": "userId",
|
||||||
"type": "text",
|
"type": "text",
|
||||||
"primaryKey": false,
|
"primaryKey": false,
|
||||||
"notNull": true
|
"notNull": true
|
||||||
},
|
},
|
||||||
"accessToken": {
|
"accessToken": {
|
||||||
"name": "accessToken",
|
"name": "accessToken",
|
||||||
"type": "text",
|
"type": "text",
|
||||||
"primaryKey": false,
|
"primaryKey": false,
|
||||||
"notNull": false
|
"notNull": false
|
||||||
},
|
},
|
||||||
"refreshToken": {
|
"refreshToken": {
|
||||||
"name": "refreshToken",
|
"name": "refreshToken",
|
||||||
"type": "text",
|
"type": "text",
|
||||||
"primaryKey": false,
|
"primaryKey": false,
|
||||||
"notNull": false
|
"notNull": false
|
||||||
},
|
},
|
||||||
"accessTokenExpiresAt": {
|
"accessTokenExpiresAt": {
|
||||||
"name": "accessTokenExpiresAt",
|
"name": "accessTokenExpiresAt",
|
||||||
"type": "timestamp",
|
"type": "timestamp",
|
||||||
"primaryKey": false,
|
"primaryKey": false,
|
||||||
"notNull": false
|
"notNull": false
|
||||||
},
|
},
|
||||||
"refreshTokenExpiresAt": {
|
"refreshTokenExpiresAt": {
|
||||||
"name": "refreshTokenExpiresAt",
|
"name": "refreshTokenExpiresAt",
|
||||||
"type": "timestamp",
|
"type": "timestamp",
|
||||||
"primaryKey": false,
|
"primaryKey": false,
|
||||||
"notNull": false
|
"notNull": false
|
||||||
},
|
},
|
||||||
"scope": {
|
"scope": {
|
||||||
"name": "scope",
|
"name": "scope",
|
||||||
"type": "text",
|
"type": "text",
|
||||||
"primaryKey": false,
|
"primaryKey": false,
|
||||||
"notNull": false
|
"notNull": false
|
||||||
},
|
},
|
||||||
"idToken": {
|
"idToken": {
|
||||||
"name": "idToken",
|
"name": "idToken",
|
||||||
"type": "text",
|
"type": "text",
|
||||||
"primaryKey": false,
|
"primaryKey": false,
|
||||||
"notNull": false
|
"notNull": false
|
||||||
},
|
},
|
||||||
"password": {
|
"password": {
|
||||||
"name": "password",
|
"name": "password",
|
||||||
"type": "text",
|
"type": "text",
|
||||||
"primaryKey": false,
|
"primaryKey": false,
|
||||||
"notNull": false
|
"notNull": false
|
||||||
},
|
},
|
||||||
"createdAt": {
|
"createdAt": {
|
||||||
"name": "createdAt",
|
"name": "createdAt",
|
||||||
"type": "timestamp",
|
"type": "timestamp",
|
||||||
"primaryKey": false,
|
"primaryKey": false,
|
||||||
"notNull": false,
|
"notNull": false,
|
||||||
"default": "now()"
|
"default": "now()"
|
||||||
},
|
},
|
||||||
"updatedAt": {
|
"updatedAt": {
|
||||||
"name": "updatedAt",
|
"name": "updatedAt",
|
||||||
"type": "timestamp",
|
"type": "timestamp",
|
||||||
"primaryKey": false,
|
"primaryKey": false,
|
||||||
"notNull": false,
|
"notNull": false,
|
||||||
"default": "now()"
|
"default": "now()"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"indexes": {},
|
"indexes": {},
|
||||||
"foreignKeys": {
|
"foreignKeys": {
|
||||||
"account_userId_user_id_fk": {
|
"account_userId_user_id_fk": {
|
||||||
"name": "account_userId_user_id_fk",
|
"name": "account_userId_user_id_fk",
|
||||||
"tableFrom": "account",
|
"tableFrom": "account",
|
||||||
"tableTo": "user",
|
"tableTo": "user",
|
||||||
"columnsFrom": [
|
"columnsFrom": ["userId"],
|
||||||
"userId"
|
"columnsTo": ["id"],
|
||||||
],
|
"onDelete": "cascade",
|
||||||
"columnsTo": [
|
"onUpdate": "no action"
|
||||||
"id"
|
}
|
||||||
],
|
},
|
||||||
"onDelete": "cascade",
|
"compositePrimaryKeys": {},
|
||||||
"onUpdate": "no action"
|
"uniqueConstraints": {},
|
||||||
}
|
"policies": {},
|
||||||
},
|
"checkConstraints": {},
|
||||||
"compositePrimaryKeys": {},
|
"isRLSEnabled": false
|
||||||
"uniqueConstraints": {},
|
},
|
||||||
"policies": {},
|
"public.session": {
|
||||||
"checkConstraints": {},
|
"name": "session",
|
||||||
"isRLSEnabled": false
|
"schema": "",
|
||||||
},
|
"columns": {
|
||||||
"public.session": {
|
"id": {
|
||||||
"name": "session",
|
"name": "id",
|
||||||
"schema": "",
|
"type": "text",
|
||||||
"columns": {
|
"primaryKey": true,
|
||||||
"id": {
|
"notNull": true
|
||||||
"name": "id",
|
},
|
||||||
"type": "text",
|
"expiresAt": {
|
||||||
"primaryKey": true,
|
"name": "expiresAt",
|
||||||
"notNull": true
|
"type": "timestamp",
|
||||||
},
|
"primaryKey": false,
|
||||||
"expiresAt": {
|
"notNull": true
|
||||||
"name": "expiresAt",
|
},
|
||||||
"type": "timestamp",
|
"token": {
|
||||||
"primaryKey": false,
|
"name": "token",
|
||||||
"notNull": true
|
"type": "text",
|
||||||
},
|
"primaryKey": false,
|
||||||
"token": {
|
"notNull": true
|
||||||
"name": "token",
|
},
|
||||||
"type": "text",
|
"createdAt": {
|
||||||
"primaryKey": false,
|
"name": "createdAt",
|
||||||
"notNull": true
|
"type": "timestamp",
|
||||||
},
|
"primaryKey": false,
|
||||||
"createdAt": {
|
"notNull": false,
|
||||||
"name": "createdAt",
|
"default": "now()"
|
||||||
"type": "timestamp",
|
},
|
||||||
"primaryKey": false,
|
"updatedAt": {
|
||||||
"notNull": false,
|
"name": "updatedAt",
|
||||||
"default": "now()"
|
"type": "timestamp",
|
||||||
},
|
"primaryKey": false,
|
||||||
"updatedAt": {
|
"notNull": false,
|
||||||
"name": "updatedAt",
|
"default": "now()"
|
||||||
"type": "timestamp",
|
},
|
||||||
"primaryKey": false,
|
"ipAddress": {
|
||||||
"notNull": false,
|
"name": "ipAddress",
|
||||||
"default": "now()"
|
"type": "text",
|
||||||
},
|
"primaryKey": false,
|
||||||
"ipAddress": {
|
"notNull": false
|
||||||
"name": "ipAddress",
|
},
|
||||||
"type": "text",
|
"userAgent": {
|
||||||
"primaryKey": false,
|
"name": "userAgent",
|
||||||
"notNull": false
|
"type": "text",
|
||||||
},
|
"primaryKey": false,
|
||||||
"userAgent": {
|
"notNull": false
|
||||||
"name": "userAgent",
|
},
|
||||||
"type": "text",
|
"userId": {
|
||||||
"primaryKey": false,
|
"name": "userId",
|
||||||
"notNull": false
|
"type": "text",
|
||||||
},
|
"primaryKey": false,
|
||||||
"userId": {
|
"notNull": true
|
||||||
"name": "userId",
|
}
|
||||||
"type": "text",
|
},
|
||||||
"primaryKey": false,
|
"indexes": {},
|
||||||
"notNull": true
|
"foreignKeys": {
|
||||||
}
|
"session_userId_user_id_fk": {
|
||||||
},
|
"name": "session_userId_user_id_fk",
|
||||||
"indexes": {},
|
"tableFrom": "session",
|
||||||
"foreignKeys": {
|
"tableTo": "user",
|
||||||
"session_userId_user_id_fk": {
|
"columnsFrom": ["userId"],
|
||||||
"name": "session_userId_user_id_fk",
|
"columnsTo": ["id"],
|
||||||
"tableFrom": "session",
|
"onDelete": "cascade",
|
||||||
"tableTo": "user",
|
"onUpdate": "no action"
|
||||||
"columnsFrom": [
|
}
|
||||||
"userId"
|
},
|
||||||
],
|
"compositePrimaryKeys": {},
|
||||||
"columnsTo": [
|
"uniqueConstraints": {
|
||||||
"id"
|
"session_token_unique": {
|
||||||
],
|
"name": "session_token_unique",
|
||||||
"onDelete": "cascade",
|
"nullsNotDistinct": false,
|
||||||
"onUpdate": "no action"
|
"columns": ["token"]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"compositePrimaryKeys": {},
|
"policies": {},
|
||||||
"uniqueConstraints": {
|
"checkConstraints": {},
|
||||||
"session_token_unique": {
|
"isRLSEnabled": false
|
||||||
"name": "session_token_unique",
|
},
|
||||||
"nullsNotDistinct": false,
|
"public.user": {
|
||||||
"columns": [
|
"name": "user",
|
||||||
"token"
|
"schema": "",
|
||||||
]
|
"columns": {
|
||||||
}
|
"id": {
|
||||||
},
|
"name": "id",
|
||||||
"policies": {},
|
"type": "text",
|
||||||
"checkConstraints": {},
|
"primaryKey": true,
|
||||||
"isRLSEnabled": false
|
"notNull": true
|
||||||
},
|
},
|
||||||
"public.user": {
|
"name": {
|
||||||
"name": "user",
|
"name": "name",
|
||||||
"schema": "",
|
"type": "text",
|
||||||
"columns": {
|
"primaryKey": false,
|
||||||
"id": {
|
"notNull": false
|
||||||
"name": "id",
|
},
|
||||||
"type": "text",
|
"email": {
|
||||||
"primaryKey": true,
|
"name": "email",
|
||||||
"notNull": true
|
"type": "text",
|
||||||
},
|
"primaryKey": false,
|
||||||
"name": {
|
"notNull": true
|
||||||
"name": "name",
|
},
|
||||||
"type": "text",
|
"emailVerified": {
|
||||||
"primaryKey": false,
|
"name": "emailVerified",
|
||||||
"notNull": false
|
"type": "boolean",
|
||||||
},
|
"primaryKey": false,
|
||||||
"email": {
|
"notNull": false,
|
||||||
"name": "email",
|
"default": false
|
||||||
"type": "text",
|
},
|
||||||
"primaryKey": false,
|
"image": {
|
||||||
"notNull": true
|
"name": "image",
|
||||||
},
|
"type": "text",
|
||||||
"emailVerified": {
|
"primaryKey": false,
|
||||||
"name": "emailVerified",
|
"notNull": false
|
||||||
"type": "boolean",
|
},
|
||||||
"primaryKey": false,
|
"createdAt": {
|
||||||
"notNull": false,
|
"name": "createdAt",
|
||||||
"default": false
|
"type": "timestamp",
|
||||||
},
|
"primaryKey": false,
|
||||||
"image": {
|
"notNull": false,
|
||||||
"name": "image",
|
"default": "now()"
|
||||||
"type": "text",
|
},
|
||||||
"primaryKey": false,
|
"updatedAt": {
|
||||||
"notNull": false
|
"name": "updatedAt",
|
||||||
},
|
"type": "timestamp",
|
||||||
"createdAt": {
|
"primaryKey": false,
|
||||||
"name": "createdAt",
|
"notNull": false,
|
||||||
"type": "timestamp",
|
"default": "now()"
|
||||||
"primaryKey": false,
|
}
|
||||||
"notNull": false,
|
},
|
||||||
"default": "now()"
|
"indexes": {},
|
||||||
},
|
"foreignKeys": {},
|
||||||
"updatedAt": {
|
"compositePrimaryKeys": {},
|
||||||
"name": "updatedAt",
|
"uniqueConstraints": {
|
||||||
"type": "timestamp",
|
"user_email_unique": {
|
||||||
"primaryKey": false,
|
"name": "user_email_unique",
|
||||||
"notNull": false,
|
"nullsNotDistinct": false,
|
||||||
"default": "now()"
|
"columns": ["email"]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"indexes": {},
|
"policies": {},
|
||||||
"foreignKeys": {},
|
"checkConstraints": {},
|
||||||
"compositePrimaryKeys": {},
|
"isRLSEnabled": false
|
||||||
"uniqueConstraints": {
|
},
|
||||||
"user_email_unique": {
|
"public.verification": {
|
||||||
"name": "user_email_unique",
|
"name": "verification",
|
||||||
"nullsNotDistinct": false,
|
"schema": "",
|
||||||
"columns": [
|
"columns": {
|
||||||
"email"
|
"id": {
|
||||||
]
|
"name": "id",
|
||||||
}
|
"type": "text",
|
||||||
},
|
"primaryKey": true,
|
||||||
"policies": {},
|
"notNull": true
|
||||||
"checkConstraints": {},
|
},
|
||||||
"isRLSEnabled": false
|
"identifier": {
|
||||||
},
|
"name": "identifier",
|
||||||
"public.verification": {
|
"type": "text",
|
||||||
"name": "verification",
|
"primaryKey": false,
|
||||||
"schema": "",
|
"notNull": true
|
||||||
"columns": {
|
},
|
||||||
"id": {
|
"value": {
|
||||||
"name": "id",
|
"name": "value",
|
||||||
"type": "text",
|
"type": "text",
|
||||||
"primaryKey": true,
|
"primaryKey": false,
|
||||||
"notNull": true
|
"notNull": true
|
||||||
},
|
},
|
||||||
"identifier": {
|
"expiresAt": {
|
||||||
"name": "identifier",
|
"name": "expiresAt",
|
||||||
"type": "text",
|
"type": "timestamp",
|
||||||
"primaryKey": false,
|
"primaryKey": false,
|
||||||
"notNull": true
|
"notNull": true
|
||||||
},
|
},
|
||||||
"value": {
|
"createdAt": {
|
||||||
"name": "value",
|
"name": "createdAt",
|
||||||
"type": "text",
|
"type": "timestamp",
|
||||||
"primaryKey": false,
|
"primaryKey": false,
|
||||||
"notNull": true
|
"notNull": false,
|
||||||
},
|
"default": "now()"
|
||||||
"expiresAt": {
|
},
|
||||||
"name": "expiresAt",
|
"updatedAt": {
|
||||||
"type": "timestamp",
|
"name": "updatedAt",
|
||||||
"primaryKey": false,
|
"type": "timestamp",
|
||||||
"notNull": true
|
"primaryKey": false,
|
||||||
},
|
"notNull": false,
|
||||||
"createdAt": {
|
"default": "now()"
|
||||||
"name": "createdAt",
|
}
|
||||||
"type": "timestamp",
|
},
|
||||||
"primaryKey": false,
|
"indexes": {},
|
||||||
"notNull": false,
|
"foreignKeys": {},
|
||||||
"default": "now()"
|
"compositePrimaryKeys": {},
|
||||||
},
|
"uniqueConstraints": {},
|
||||||
"updatedAt": {
|
"policies": {},
|
||||||
"name": "updatedAt",
|
"checkConstraints": {},
|
||||||
"type": "timestamp",
|
"isRLSEnabled": false
|
||||||
"primaryKey": false,
|
}
|
||||||
"notNull": false,
|
},
|
||||||
"default": "now()"
|
"enums": {},
|
||||||
}
|
"schemas": {},
|
||||||
},
|
"sequences": {},
|
||||||
"indexes": {},
|
"roles": {},
|
||||||
"foreignKeys": {},
|
"policies": {},
|
||||||
"compositePrimaryKeys": {},
|
"views": {},
|
||||||
"uniqueConstraints": {},
|
"_meta": {
|
||||||
"policies": {},
|
"columns": {},
|
||||||
"checkConstraints": {},
|
"schemas": {},
|
||||||
"isRLSEnabled": false
|
"tables": {}
|
||||||
}
|
}
|
||||||
},
|
}
|
||||||
"enums": {},
|
|
||||||
"schemas": {},
|
|
||||||
"sequences": {},
|
|
||||||
"roles": {},
|
|
||||||
"policies": {},
|
|
||||||
"views": {},
|
|
||||||
"_meta": {
|
|
||||||
"columns": {},
|
|
||||||
"schemas": {},
|
|
||||||
"tables": {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,20 +1,20 @@
|
|||||||
{
|
{
|
||||||
"version": "7",
|
"version": "7",
|
||||||
"dialect": "postgresql",
|
"dialect": "postgresql",
|
||||||
"entries": [
|
"entries": [
|
||||||
{
|
{
|
||||||
"idx": 0,
|
"idx": 0,
|
||||||
"version": "7",
|
"version": "7",
|
||||||
"when": 1755586325384,
|
"when": 1755586325384,
|
||||||
"tag": "0000_loose_catseye",
|
"tag": "0000_loose_catseye",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"idx": 1,
|
"idx": 1,
|
||||||
"version": "7",
|
"version": "7",
|
||||||
"when": 1775526538601,
|
"when": 1775526538601,
|
||||||
"tag": "0001_great_sentry",
|
"tag": "0001_great_sentry",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,21 +1,21 @@
|
|||||||
import { relations } from "drizzle-orm/relations";
|
import { relations } from "drizzle-orm/relations";
|
||||||
import { user, session, account } from "./schema";
|
import { user, session, account } from "./schema";
|
||||||
|
|
||||||
export const sessionRelations = relations(session, ({one}) => ({
|
export const sessionRelations = relations(session, ({ one }) => ({
|
||||||
user: one(user, {
|
user: one(user, {
|
||||||
fields: [session.userId],
|
fields: [session.userId],
|
||||||
references: [user.id]
|
references: [user.id],
|
||||||
}),
|
}),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
export const userRelations = relations(user, ({many}) => ({
|
export const userRelations = relations(user, ({ many }) => ({
|
||||||
sessions: many(session),
|
sessions: many(session),
|
||||||
accounts: many(account),
|
accounts: many(account),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
export const accountRelations = relations(account, ({one}) => ({
|
export const accountRelations = relations(account, ({ one }) => ({
|
||||||
user: one(user, {
|
user: one(user, {
|
||||||
fields: [account.userId],
|
fields: [account.userId],
|
||||||
references: [user.id]
|
references: [user.id],
|
||||||
}),
|
}),
|
||||||
}));
|
}));
|
||||||
|
|||||||
@@ -1,72 +1,104 @@
|
|||||||
import { pgTable, foreignKey, text, timestamp, primaryKey, unique, integer, boolean } from "drizzle-orm/pg-core"
|
import {
|
||||||
import { sql } from "drizzle-orm"
|
pgTable,
|
||||||
|
foreignKey,
|
||||||
|
text,
|
||||||
|
timestamp,
|
||||||
|
primaryKey,
|
||||||
|
unique,
|
||||||
|
integer,
|
||||||
|
boolean,
|
||||||
|
} from "drizzle-orm/pg-core";
|
||||||
|
import { sql } from "drizzle-orm";
|
||||||
|
|
||||||
|
export const session = pgTable(
|
||||||
|
"session",
|
||||||
export const session = pgTable("session", {
|
{
|
||||||
sessionToken: text().primaryKey().notNull(),
|
sessionToken: text().primaryKey().notNull(),
|
||||||
userId: text().notNull(),
|
userId: text().notNull(),
|
||||||
expires: timestamp({ mode: 'string' }).notNull(),
|
expires: timestamp({ mode: "string" }).notNull(),
|
||||||
}, (table) => [
|
},
|
||||||
foreignKey({
|
(table) => [
|
||||||
|
foreignKey({
|
||||||
columns: [table.userId],
|
columns: [table.userId],
|
||||||
foreignColumns: [user.id],
|
foreignColumns: [user.id],
|
||||||
name: "session_userId_user_id_fk"
|
name: "session_userId_user_id_fk",
|
||||||
}).onDelete("cascade"),
|
}).onDelete("cascade"),
|
||||||
]);
|
],
|
||||||
|
);
|
||||||
|
|
||||||
export const user = pgTable("user", {
|
export const user = pgTable("user", {
|
||||||
id: text().primaryKey().notNull(),
|
id: text().primaryKey().notNull(),
|
||||||
name: text(),
|
name: text(),
|
||||||
email: text().notNull(),
|
email: text().notNull(),
|
||||||
emailVerified: timestamp({ mode: 'string' }),
|
emailVerified: timestamp({ mode: "string" }),
|
||||||
image: text(),
|
image: text(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const verificationToken = pgTable("verificationToken", {
|
export const verificationToken = pgTable(
|
||||||
identifier: text().notNull(),
|
"verificationToken",
|
||||||
token: text().notNull(),
|
{
|
||||||
expires: timestamp({ mode: 'string' }).notNull(),
|
identifier: text().notNull(),
|
||||||
}, (table) => [
|
token: text().notNull(),
|
||||||
primaryKey({ columns: [table.identifier, table.token], name: "verificationToken_identifier_token_pk"}),
|
expires: timestamp({ mode: "string" }).notNull(),
|
||||||
]);
|
},
|
||||||
|
(table) => [
|
||||||
|
primaryKey({
|
||||||
|
columns: [table.identifier, table.token],
|
||||||
|
name: "verificationToken_identifier_token_pk",
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
export const authenticator = pgTable("authenticator", {
|
export const authenticator = pgTable(
|
||||||
credentialId: text().notNull(),
|
"authenticator",
|
||||||
userId: text().notNull(),
|
{
|
||||||
providerAccountId: text().notNull(),
|
credentialId: text().notNull(),
|
||||||
credentialPublicKey: text().notNull(),
|
userId: text().notNull(),
|
||||||
counter: integer().notNull(),
|
providerAccountId: text().notNull(),
|
||||||
credentialDeviceType: text().notNull(),
|
credentialPublicKey: text().notNull(),
|
||||||
credentialBackedUp: boolean().notNull(),
|
counter: integer().notNull(),
|
||||||
transports: text(),
|
credentialDeviceType: text().notNull(),
|
||||||
}, (table) => [
|
credentialBackedUp: boolean().notNull(),
|
||||||
foreignKey({
|
transports: text(),
|
||||||
|
},
|
||||||
|
(table) => [
|
||||||
|
foreignKey({
|
||||||
columns: [table.userId],
|
columns: [table.userId],
|
||||||
foreignColumns: [user.id],
|
foreignColumns: [user.id],
|
||||||
name: "authenticator_userId_user_id_fk"
|
name: "authenticator_userId_user_id_fk",
|
||||||
}).onDelete("cascade"),
|
}).onDelete("cascade"),
|
||||||
primaryKey({ columns: [table.credentialId, table.userId], name: "authenticator_userId_credentialID_pk"}),
|
primaryKey({
|
||||||
unique("authenticator_credentialID_unique").on(table.credentialId),
|
columns: [table.credentialId, table.userId],
|
||||||
]);
|
name: "authenticator_userId_credentialID_pk",
|
||||||
|
}),
|
||||||
|
unique("authenticator_credentialID_unique").on(table.credentialId),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
export const account = pgTable("account", {
|
export const account = pgTable(
|
||||||
userId: text().notNull(),
|
"account",
|
||||||
type: text().notNull(),
|
{
|
||||||
provider: text().notNull(),
|
userId: text().notNull(),
|
||||||
providerAccountId: text().notNull(),
|
type: text().notNull(),
|
||||||
refreshToken: text("refresh_token"),
|
provider: text().notNull(),
|
||||||
accessToken: text("access_token"),
|
providerAccountId: text().notNull(),
|
||||||
expiresAt: text("expires_at"),
|
refreshToken: text("refresh_token"),
|
||||||
tokenType: text("token_type"),
|
accessToken: text("access_token"),
|
||||||
scope: text(),
|
expiresAt: text("expires_at"),
|
||||||
idToken: text("id_token"),
|
tokenType: text("token_type"),
|
||||||
sessionState: text("session_state"),
|
scope: text(),
|
||||||
}, (table) => [
|
idToken: text("id_token"),
|
||||||
foreignKey({
|
sessionState: text("session_state"),
|
||||||
|
},
|
||||||
|
(table) => [
|
||||||
|
foreignKey({
|
||||||
columns: [table.userId],
|
columns: [table.userId],
|
||||||
foreignColumns: [user.id],
|
foreignColumns: [user.id],
|
||||||
name: "account_userId_user_id_fk"
|
name: "account_userId_user_id_fk",
|
||||||
}).onDelete("cascade"),
|
}).onDelete("cascade"),
|
||||||
primaryKey({ columns: [table.provider, table.providerAccountId], name: "account_provider_providerAccountId_pk"}),
|
primaryKey({
|
||||||
]);
|
columns: [table.provider, table.providerAccountId],
|
||||||
|
name: "account_provider_providerAccountId_pk",
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|||||||
@@ -6,11 +6,11 @@ const __filename = fileURLToPath(import.meta.url);
|
|||||||
const __dirname = dirname(__filename);
|
const __dirname = dirname(__filename);
|
||||||
|
|
||||||
const compat = new FlatCompat({
|
const compat = new FlatCompat({
|
||||||
baseDirectory: __dirname,
|
baseDirectory: __dirname,
|
||||||
});
|
});
|
||||||
|
|
||||||
const eslintConfig = [
|
const eslintConfig = [
|
||||||
...compat.extends("next/core-web-vitals", "next/typescript"),
|
...compat.extends("next/core-web-vitals", "next/typescript"),
|
||||||
];
|
];
|
||||||
|
|
||||||
export default eslintConfig;
|
export default eslintConfig;
|
||||||
|
|||||||
109
package.json
109
package.json
@@ -1,56 +1,57 @@
|
|||||||
{
|
{
|
||||||
"name": "ical-pwa",
|
"name": "ical-pwa",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "next dev --turbopack",
|
"dev": "next dev --turbopack",
|
||||||
"build": "next build",
|
"build": "next build",
|
||||||
"start": "next start",
|
"start": "next start",
|
||||||
"lint": "next lint"
|
"lint": "next lint"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@radix-ui/react-checkbox": "^1.3.3",
|
"@openrouter/sdk": "^0.11.2",
|
||||||
"@radix-ui/react-dialog": "^1.1.15",
|
"@radix-ui/react-checkbox": "^1.3.3",
|
||||||
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
"@radix-ui/react-dialog": "^1.1.15",
|
||||||
"@radix-ui/react-label": "^2.1.7",
|
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
||||||
"@radix-ui/react-select": "^2.2.6",
|
"@radix-ui/react-label": "^2.1.7",
|
||||||
"@radix-ui/react-slot": "^1.2.3",
|
"@radix-ui/react-select": "^2.2.6",
|
||||||
"better-auth": "^1.6.0",
|
"@radix-ui/react-slot": "^1.2.3",
|
||||||
"class-variance-authority": "^0.7.1",
|
"better-auth": "^1.6.0",
|
||||||
"clsx": "^2.1.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"date-fns": "^4.1.0",
|
"clsx": "^2.1.1",
|
||||||
"dotenv": "^17.2.1",
|
"date-fns": "^4.1.0",
|
||||||
"drizzle-orm": "^0.44.4",
|
"dotenv": "^17.2.1",
|
||||||
"ical.js": "^2.2.1",
|
"drizzle-orm": "^0.44.4",
|
||||||
"idb": "^8.0.3",
|
"ical.js": "^2.2.1",
|
||||||
"lucide-react": "^0.539.0",
|
"idb": "^8.0.3",
|
||||||
"nanoid": "^5.1.5",
|
"lucide-react": "^0.539.0",
|
||||||
"next": "15.4.10",
|
"nanoid": "^5.1.5",
|
||||||
"next-themes": "^0.4.6",
|
"next": "15.4.10",
|
||||||
"pg": "^8.16.3",
|
"next-themes": "^0.4.6",
|
||||||
"postgres": "^3.4.7",
|
"pg": "^8.16.3",
|
||||||
"react": "19.1.0",
|
"postgres": "^3.4.7",
|
||||||
"react-day-picker": "^9.9.0",
|
"react": "19.1.0",
|
||||||
"react-dom": "19.1.0",
|
"react-day-picker": "^9.9.0",
|
||||||
"sonner": "^2.0.7",
|
"react-dom": "19.1.0",
|
||||||
"tailwind-merge": "^3.3.1"
|
"sonner": "^2.0.7",
|
||||||
},
|
"tailwind-merge": "^3.3.1"
|
||||||
"devDependencies": {
|
},
|
||||||
"@eslint/eslintrc": "^3",
|
"devDependencies": {
|
||||||
"@tailwindcss/postcss": "^4",
|
"@eslint/eslintrc": "^3",
|
||||||
"@types/node": "^20",
|
"@tailwindcss/postcss": "^4",
|
||||||
"@types/pg": "^8.15.5",
|
"@types/node": "^20",
|
||||||
"@types/react": "^19",
|
"@types/pg": "^8.15.5",
|
||||||
"@types/react-dom": "^19",
|
"@types/react": "^19",
|
||||||
"drizzle-kit": "^0.31.4",
|
"@types/react-dom": "^19",
|
||||||
"eslint": "^9",
|
"drizzle-kit": "^0.31.4",
|
||||||
"eslint-config-next": "15.4.6",
|
"eslint": "^9",
|
||||||
"tailwindcss": "^4",
|
"eslint-config-next": "15.4.6",
|
||||||
"tsx": "^4.20.4",
|
"tailwindcss": "^4",
|
||||||
"tw-animate-css": "^1.3.6",
|
"tsx": "^4.20.4",
|
||||||
"typescript": "^5"
|
"tw-animate-css": "^1.3.6",
|
||||||
},
|
"typescript": "^5"
|
||||||
"overrides": {
|
},
|
||||||
"@types/minimatch": "5.1.2"
|
"overrides": {
|
||||||
}
|
"@types/minimatch": "5.1.2"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
const config = {
|
const config = {
|
||||||
plugins: ["@tailwindcss/postcss"],
|
plugins: ["@tailwindcss/postcss"],
|
||||||
};
|
};
|
||||||
|
|
||||||
export default config;
|
export default config;
|
||||||
|
|||||||
@@ -1,20 +1,20 @@
|
|||||||
{
|
{
|
||||||
"name": "iCal PWA",
|
"name": "iCal PWA",
|
||||||
"short_name": "iCal",
|
"short_name": "iCal",
|
||||||
"start_url": "/",
|
"start_url": "/",
|
||||||
"background_color": "#ffffff",
|
"background_color": "#ffffff",
|
||||||
"theme_color": "#1d4ed8",
|
"theme_color": "#1d4ed8",
|
||||||
"display": "standalone",
|
"display": "standalone",
|
||||||
"icons": [
|
"icons": [
|
||||||
{
|
{
|
||||||
"src": "/icons/icon-192.png",
|
"src": "/icons/icon-192.png",
|
||||||
"sizes": "192x192",
|
"sizes": "192x192",
|
||||||
"type": "image/png"
|
"type": "image/png"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"src": "/icons/icon-512.png",
|
"src": "/icons/icon-512.png",
|
||||||
"sizes": "512x512",
|
"sizes": "512x512",
|
||||||
"type": "image/png"
|
"type": "image/png"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,16 +1,17 @@
|
|||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
import { auth } from "@/auth";
|
import { auth } from "@/auth";
|
||||||
import { headers } from "next/headers";
|
import { headers } from "next/headers";
|
||||||
|
import { openRouterClient } from "@/lib/openrouter-client";
|
||||||
|
|
||||||
export async function POST(request: Request) {
|
export async function POST(request: Request) {
|
||||||
const session = await auth.api.getSession({
|
const session = await auth.api.getSession({
|
||||||
headers: await headers(),
|
headers: await headers(),
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!session?.user) {
|
if (!session?.user) {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ error: "Authentication required" },
|
{ error: "Authentication required" },
|
||||||
{ status: 401 }
|
{ status: 401 },
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -20,13 +21,13 @@ export async function POST(request: Request) {
|
|||||||
if (!prompt || typeof prompt !== "string" || prompt.trim().length === 0) {
|
if (!prompt || typeof prompt !== "string" || prompt.trim().length === 0) {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ error: "Prompt is required and must be a non-empty string" },
|
{ error: "Prompt is required and must be a non-empty string" },
|
||||||
{ status: 400 }
|
{ status: 400 },
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (prompt.length > 2000) {
|
if (prompt.length > 2000) {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ error: "Prompt must be less than 2000 characters" },
|
{ error: "Prompt must be less than 2000 characters" },
|
||||||
{ status: 400 }
|
{ status: 400 },
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -57,29 +58,23 @@ Rules:
|
|||||||
- Output ONLY valid JSON (no prose).
|
- Output ONLY valid JSON (no prose).
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const res = await fetch("https://openrouter.ai/api/v1/chat/completions", {
|
|
||||||
method: "POST",
|
|
||||||
headers: {
|
|
||||||
Authorization: `Bearer ${process.env.OPENROUTER_API_KEY}`,
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
},
|
|
||||||
body: JSON.stringify({
|
|
||||||
model: "openai/gpt-4.1-nano",
|
|
||||||
messages: [
|
|
||||||
{ role: "system", content: systemPrompt },
|
|
||||||
{ role: "user", content: prompt },
|
|
||||||
],
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
|
|
||||||
const data = await res.json();
|
|
||||||
try {
|
try {
|
||||||
const content = data.choices[0].message.content;
|
const result = openRouterClient.callModel({
|
||||||
const parsed = JSON.parse(content);
|
model: "openai/gpt-5.4-mini",
|
||||||
|
instructions: systemPrompt,
|
||||||
|
input: prompt,
|
||||||
|
});
|
||||||
|
|
||||||
|
const text = await result.getText();
|
||||||
|
const parsed = JSON.parse(text);
|
||||||
return NextResponse.json(parsed);
|
return NextResponse.json(parsed);
|
||||||
} catch {
|
} catch (error) {
|
||||||
|
console.error("AI Event Creation Error:", error);
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ error: "Failed to parse AI output", raw: data },
|
{
|
||||||
|
error: "Failed to parse AI output",
|
||||||
|
raw: error instanceof Error ? error.message : error,
|
||||||
|
},
|
||||||
{ status: 500 },
|
{ status: 500 },
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
import { auth } from "@/auth";
|
import { auth } from "@/auth";
|
||||||
import { headers } from "next/headers";
|
import { headers } from "next/headers";
|
||||||
|
import { openRouterClient } from "@/lib/openrouter-client";
|
||||||
|
|
||||||
export async function POST(request: Request) {
|
export async function POST(request: Request) {
|
||||||
const session = await auth.api.getSession({
|
const session = await auth.api.getSession({
|
||||||
@@ -30,32 +31,18 @@ export async function POST(request: Request) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const res = await fetch("https://openrouter.ai/api/v1/chat/completions", {
|
const result = openRouterClient.callModel({
|
||||||
method: "POST",
|
model: "@preset/i-cal-editor-summarize", // FREE model
|
||||||
headers: {
|
instructions:
|
||||||
Authorization: `Bearer ${process.env.OPENROUTER_API_KEY}`, // Server-side only
|
"You summarize a list of events in natural language. Include date, time, and title. Be concise.",
|
||||||
"Content-Type": "application/json",
|
input: JSON.stringify(events),
|
||||||
},
|
temperature: 0.4,
|
||||||
body: JSON.stringify({
|
|
||||||
model: "@preset/i-cal-editor-summarize", // FREE model
|
|
||||||
messages: [
|
|
||||||
{
|
|
||||||
role: "system",
|
|
||||||
content: `You summarize a list of events in natural language. Include date, time, and title. Be concise.`,
|
|
||||||
},
|
|
||||||
{ role: "user", content: JSON.stringify(events) },
|
|
||||||
],
|
|
||||||
temperature: 0.4,
|
|
||||||
// max_tokens: 300,
|
|
||||||
}),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const data = await res.json();
|
const summary = await result.getText();
|
||||||
const summary =
|
|
||||||
data?.choices?.[0]?.message?.content || "No summary generated.";
|
|
||||||
return NextResponse.json({ summary });
|
return NextResponse.json({ summary });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(error);
|
console.error("AI Summary Error:", error);
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ error: "Failed to summarize events" },
|
{ error: "Failed to summarize events" },
|
||||||
{ status: 500 },
|
{ status: 500 },
|
||||||
|
|||||||
@@ -1,43 +1,45 @@
|
|||||||
"use client"
|
"use client";
|
||||||
|
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button";
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
import Link from "next/link"
|
import Link from "next/link";
|
||||||
import { useSearchParams } from "next/navigation"
|
import { useSearchParams } from "next/navigation";
|
||||||
import { Suspense } from "react"
|
import { Suspense } from "react";
|
||||||
|
|
||||||
function Search() {
|
function Search() {
|
||||||
const searchParams = useSearchParams()
|
const searchParams = useSearchParams();
|
||||||
const errorMessage = searchParams.get('error')
|
const errorMessage = searchParams.get("error");
|
||||||
|
|
||||||
// Sanitize error message to prevent XSS
|
|
||||||
const sanitizedError = errorMessage
|
|
||||||
? errorMessage.replace(/[<>]/g, '')
|
|
||||||
: 'An authentication error occurred'
|
|
||||||
|
|
||||||
return (<div className="text-center p-3 bg-background rounded-lg">
|
// Sanitize error message to prevent XSS
|
||||||
{sanitizedError}
|
const sanitizedError = errorMessage
|
||||||
</div>)
|
? errorMessage.replace(/[<>]/g, "")
|
||||||
|
: "An authentication error occurred";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="text-center p-3 bg-background rounded-lg">
|
||||||
|
{sanitizedError}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function AuthErrorPage() {
|
export default function AuthErrorPage() {
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen flex items-center justify-center bg-background p-4">
|
<div className="min-h-screen flex items-center justify-center bg-background p-4">
|
||||||
<Card className="w-full max-w-md bg-red-400 dark:bg-red-600">
|
<Card className="w-full max-w-md bg-red-400 dark:bg-red-600">
|
||||||
<CardHeader className="text-center">
|
<CardHeader className="text-center">
|
||||||
<CardTitle className="text-2xl font-bold">Error</CardTitle>
|
<CardTitle className="text-2xl font-bold">Error</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-4">
|
<CardContent className="space-y-4">
|
||||||
<Suspense>
|
<Suspense>
|
||||||
<Search />
|
<Search />
|
||||||
</Suspense>
|
</Suspense>
|
||||||
<div className="flex flex-row">
|
<div className="flex flex-row">
|
||||||
<Button variant="secondary" asChild>
|
<Button variant="secondary" asChild>
|
||||||
<Link href="/">Go back to Homepage</Link>
|
<Link href="/">Go back to Homepage</Link>
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,67 +1,81 @@
|
|||||||
"use client"
|
"use client";
|
||||||
|
|
||||||
import { signIn, useSession } from "@/lib/auth-client"
|
import { signIn, useSession } from "@/lib/auth-client";
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button";
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
|
import {
|
||||||
import Link from "next/link"
|
Card,
|
||||||
import { useRouter } from "next/navigation"
|
CardContent,
|
||||||
import { useEffect, useState } from "react"
|
CardDescription,
|
||||||
import { toast } from "sonner"
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from "@/components/ui/card";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
|
||||||
export default function SignInPage() {
|
export default function SignInPage() {
|
||||||
const { data: session, isPending } = useSession()
|
const { data: session, isPending } = useSession();
|
||||||
const router = useRouter()
|
const router = useRouter();
|
||||||
const [isLoading, setIsLoading] = useState(false)
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (session?.user) {
|
if (session?.user) {
|
||||||
router.push("/")
|
router.push("/");
|
||||||
}
|
}
|
||||||
}, [session, router])
|
}, [session, router]);
|
||||||
|
|
||||||
const handleSignIn = async () => {
|
const handleSignIn = async () => {
|
||||||
setIsLoading(true)
|
setIsLoading(true);
|
||||||
try {
|
try {
|
||||||
await signIn.oauth2({
|
await signIn.oauth2({
|
||||||
providerId: "authentik",
|
providerId: "authentik",
|
||||||
callbackURL: "/",
|
callbackURL: "/",
|
||||||
})
|
});
|
||||||
} catch (_error) {
|
} catch (_error) {
|
||||||
toast.error("Failed to sign in. Please try again.")
|
toast.error("Failed to sign in. Please try again.");
|
||||||
} finally {
|
} finally {
|
||||||
setIsLoading(false)
|
setIsLoading(false);
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
|
|
||||||
if (isPending) {
|
if (isPending) {
|
||||||
return null
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (session?.user) {
|
if (session?.user) {
|
||||||
return null
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen flex items-center justify-center bg-background p-4">
|
<div className="min-h-screen flex items-center justify-center bg-background p-4">
|
||||||
<Card className="w-full max-w-md">
|
<Card className="w-full max-w-md">
|
||||||
<CardHeader className="text-center">
|
<CardHeader className="text-center">
|
||||||
<CardTitle className="text-2xl font-bold">Welcome</CardTitle>
|
<CardTitle className="text-2xl font-bold">Welcome</CardTitle>
|
||||||
<CardDescription>
|
<CardDescription>
|
||||||
Sign in to access AI-powered calendar features
|
Sign in to access AI-powered calendar features
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-4">
|
<CardContent className="space-y-4">
|
||||||
<Button onClick={handleSignIn} className="w-full" size="lg" disabled={isLoading}>
|
<Button
|
||||||
{isLoading ? "Signing in..." : "Continue with Authentik"}
|
onClick={handleSignIn}
|
||||||
</Button>
|
className="w-full"
|
||||||
|
size="lg"
|
||||||
|
disabled={isLoading}
|
||||||
|
>
|
||||||
|
{isLoading ? "Signing in..." : "Continue with Authentik"}
|
||||||
|
</Button>
|
||||||
|
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<Link href="/" className="text-sm text-muted-foreground hover:underline">
|
<Link
|
||||||
Continue without signing in
|
href="/"
|
||||||
</Link>
|
className="text-sm text-muted-foreground hover:underline"
|
||||||
</div>
|
>
|
||||||
</CardContent>
|
Continue without signing in
|
||||||
</Card>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
)
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,57 +1,69 @@
|
|||||||
"use client"
|
"use client";
|
||||||
|
|
||||||
import { signOut, useSession } from "@/lib/auth-client"
|
import { signOut, useSession } from "@/lib/auth-client";
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button";
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
|
import {
|
||||||
import Link from "next/link"
|
Card,
|
||||||
import { useRouter } from "next/navigation"
|
CardContent,
|
||||||
import { useEffect } from "react"
|
CardDescription,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from "@/components/ui/card";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { useEffect } from "react";
|
||||||
|
|
||||||
export default function SignOutPage() {
|
export default function SignOutPage() {
|
||||||
const { data: session, isPending } = useSession()
|
const { data: session, isPending } = useSession();
|
||||||
const router = useRouter()
|
const router = useRouter();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!session?.user) {
|
if (!session?.user) {
|
||||||
router.push("/")
|
router.push("/");
|
||||||
}
|
}
|
||||||
}, [session, router])
|
}, [session, router]);
|
||||||
|
|
||||||
const handleSignOut = async () => {
|
const handleSignOut = async () => {
|
||||||
await signOut()
|
await signOut();
|
||||||
router.push("/")
|
router.push("/");
|
||||||
}
|
};
|
||||||
|
|
||||||
if (isPending || !session?.user) {
|
if (isPending || !session?.user) {
|
||||||
return null
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen flex items-center justify-center bg-background p-4">
|
<div className="min-h-screen flex items-center justify-center bg-background p-4">
|
||||||
<Card className="w-full max-w-md">
|
<Card className="w-full max-w-md">
|
||||||
<CardHeader className="text-center">
|
<CardHeader className="text-center">
|
||||||
<CardTitle className="text-2xl font-bold">Sign Out</CardTitle>
|
<CardTitle className="text-2xl font-bold">Sign Out</CardTitle>
|
||||||
<CardDescription>
|
<CardDescription>Are you sure you want to sign out?</CardDescription>
|
||||||
Are you sure you want to sign out?
|
</CardHeader>
|
||||||
</CardDescription>
|
<CardContent className="space-y-4">
|
||||||
</CardHeader>
|
<div className="text-center p-3 bg-muted rounded-lg">
|
||||||
<CardContent className="space-y-4">
|
<div className="text-sm text-muted-foreground">
|
||||||
<div className="text-center p-3 bg-muted rounded-lg">
|
Currently signed in as
|
||||||
<div className="text-sm text-muted-foreground">Currently signed in as</div>
|
</div>
|
||||||
<div className="font-medium">{session.user?.name || session.user?.email}</div>
|
<div className="font-medium">
|
||||||
</div>
|
{session.user?.name || session.user?.email}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-2 gap-3">
|
<div className="grid grid-cols-2 gap-3">
|
||||||
<Button onClick={handleSignOut} variant="destructive" className="w-full">
|
<Button
|
||||||
Sign Out
|
onClick={handleSignOut}
|
||||||
</Button>
|
variant="destructive"
|
||||||
|
className="w-full"
|
||||||
|
>
|
||||||
|
Sign Out
|
||||||
|
</Button>
|
||||||
|
|
||||||
<Button variant="outline" asChild>
|
<Button variant="outline" asChild>
|
||||||
<Link href="/">Cancel</Link>
|
<Link href="/">Cancel</Link>
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,49 +5,56 @@ import { ThemeProvider } from "next-themes";
|
|||||||
import { ModeToggle } from "@/components/mode-toggle";
|
import { ModeToggle } from "@/components/mode-toggle";
|
||||||
import SignIn from "@/components/sign-in";
|
import SignIn from "@/components/sign-in";
|
||||||
import { Toaster } from "@/components/ui/sonner";
|
import { Toaster } from "@/components/ui/sonner";
|
||||||
import Link from "next/link"
|
import Link from "next/link";
|
||||||
|
|
||||||
const geist = Geist({ subsets: ['latin', 'cyrillic'], variable: "--font-geist-sans" })
|
const geist = Geist({
|
||||||
|
subsets: ["latin", "cyrillic"],
|
||||||
|
variable: "--font-geist-sans",
|
||||||
|
});
|
||||||
|
|
||||||
const magra = Magra({ subsets: ["latin"], weight: "400", variable: "--font-cascadia-code" })
|
const magra = Magra({
|
||||||
|
subsets: ["latin"],
|
||||||
|
weight: "400",
|
||||||
|
variable: "--font-cascadia-code",
|
||||||
|
});
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: 'Local iCal',
|
title: "Local iCal",
|
||||||
description: 'Local iCal editor for calendar events',
|
description: "Local iCal editor for calendar events",
|
||||||
creator: "Dmytro Stanchiev",
|
creator: "Dmytro Stanchiev",
|
||||||
}
|
};
|
||||||
|
|
||||||
export default function RootLayout({
|
export default function RootLayout({
|
||||||
children,
|
children,
|
||||||
}: Readonly<{
|
}: Readonly<{
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
}>) {
|
}>) {
|
||||||
return (
|
return (
|
||||||
<html lang="en" suppressHydrationWarning>
|
<html lang="en" suppressHydrationWarning>
|
||||||
<body
|
<body
|
||||||
className={`${geist.variable} antialiased min-h-screen flex flex-col dark:text-gray-300 --color-background`}
|
className={`${geist.variable} antialiased min-h-screen flex flex-col dark:text-gray-300 --color-background`}
|
||||||
>
|
>
|
||||||
<ThemeProvider
|
<ThemeProvider
|
||||||
attribute="class"
|
attribute="class"
|
||||||
defaultTheme="system"
|
defaultTheme="system"
|
||||||
enableSystem
|
enableSystem
|
||||||
disableTransitionOnChange
|
disableTransitionOnChange
|
||||||
>
|
>
|
||||||
<header className="dark:text-white text-gray-900 px-4 py-3 font-bold flex justify-between items-center-safe">
|
<header className="dark:text-white text-gray-900 px-4 py-3 font-bold flex justify-between items-center-safe">
|
||||||
<Link href={"/"}>
|
<Link href={"/"}>
|
||||||
<p className={`${magra.variable}`}>
|
<p className={`${magra.variable}`}>
|
||||||
{metadata.title as string || "iCal PWA"}
|
{(metadata.title as string) || "iCal PWA"}
|
||||||
</p>
|
</p>
|
||||||
</Link>
|
</Link>
|
||||||
<div className="flex flex-row gap-2">
|
<div className="flex flex-row gap-2">
|
||||||
<SignIn />
|
<SignIn />
|
||||||
<ModeToggle />
|
<ModeToggle />
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
<main className="flex-1 p-4">{children}</main>
|
<main className="flex-1 p-4">{children}</main>
|
||||||
<Toaster closeButton richColors />
|
<Toaster closeButton richColors />
|
||||||
</ThemeProvider>
|
</ThemeProvider>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
566
src/app/page.tsx
566
src/app/page.tsx
@@ -1,309 +1,315 @@
|
|||||||
"use client"
|
"use client";
|
||||||
|
|
||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState } from "react";
|
||||||
import { nanoid } from 'nanoid'
|
import { nanoid } from "nanoid";
|
||||||
import { useSession } from '@/lib/auth-client'
|
import { useSession } from "@/lib/auth-client";
|
||||||
import { toast } from 'sonner'
|
import { toast } from "sonner";
|
||||||
|
|
||||||
import { saveEvent as addEvent, deleteEvent, getEvents as getAllEvents, clearEvents, updateEvent } from '@/lib/events-db'
|
import {
|
||||||
import { parseICS, generateICS } from '@/lib/ical'
|
saveEvent as addEvent,
|
||||||
import type { CalendarEvent } from '@/lib/types'
|
deleteEvent,
|
||||||
|
getEvents as getAllEvents,
|
||||||
|
clearEvents,
|
||||||
|
updateEvent,
|
||||||
|
} from "@/lib/events-db";
|
||||||
|
import { parseICS, generateICS } from "@/lib/ical";
|
||||||
|
import type { CalendarEvent } from "@/lib/types";
|
||||||
|
|
||||||
import { AIToolbar } from '@/components/ai-toolbar'
|
import { AIToolbar } from "@/components/ai-toolbar";
|
||||||
import { EventActionsToolbar } from '@/components/event-actions-toolbar'
|
import { EventActionsToolbar } from "@/components/event-actions-toolbar";
|
||||||
import { EventsList } from '@/components/events-list'
|
import { EventsList } from "@/components/events-list";
|
||||||
import { EventDialog } from '@/components/event-dialog'
|
import { EventDialog } from "@/components/event-dialog";
|
||||||
import { DragDropContainer } from '@/components/drag-drop-container'
|
import { DragDropContainer } from "@/components/drag-drop-container";
|
||||||
|
|
||||||
export default function HomePage() {
|
export default function HomePage() {
|
||||||
const [events, setEvents] = useState<CalendarEvent[]>([])
|
const [events, setEvents] = useState<CalendarEvent[]>([]);
|
||||||
const [dialogOpen, setDialogOpen] = useState(false)
|
const [dialogOpen, setDialogOpen] = useState(false);
|
||||||
const [editingId, setEditingId] = useState<string | null>(null)
|
const [editingId, setEditingId] = useState<string | null>(null);
|
||||||
const [isDragOver, setIsDragOver] = useState(false)
|
const [isDragOver, setIsDragOver] = useState(false);
|
||||||
|
|
||||||
// Form fields
|
// Form fields
|
||||||
const [title, setTitle] = useState('')
|
const [title, setTitle] = useState("");
|
||||||
const [description, setDescription] = useState('')
|
const [description, setDescription] = useState("");
|
||||||
const [location, setLocation] = useState('')
|
const [location, setLocation] = useState("");
|
||||||
const [url, setUrl] = useState('')
|
const [url, setUrl] = useState("");
|
||||||
const [start, setStart] = useState('')
|
const [start, setStart] = useState("");
|
||||||
const [end, setEnd] = useState('')
|
const [end, setEnd] = useState("");
|
||||||
const [allDay, setAllDay] = useState(false)
|
const [allDay, setAllDay] = useState(false);
|
||||||
const [recurrenceRule, setRecurrenceRule] = useState<string | undefined>(undefined)
|
const [recurrenceRule, setRecurrenceRule] = useState<string | undefined>(
|
||||||
|
undefined,
|
||||||
|
);
|
||||||
|
|
||||||
// AI
|
// AI
|
||||||
const [aiPrompt, setAiPrompt] = useState('')
|
const [aiPrompt, setAiPrompt] = useState("");
|
||||||
const [aiLoading, setAiLoading] = useState(false)
|
const [aiLoading, setAiLoading] = useState(false);
|
||||||
const [summary, setSummary] = useState<string | null>(null)
|
const [summary, setSummary] = useState<string | null>(null);
|
||||||
const [summaryUpdated, setSummaryUpdated] = useState<string | null>(null)
|
const [summaryUpdated, setSummaryUpdated] = useState<string | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
(async () => {
|
(async () => {
|
||||||
const stored = await getAllEvents()
|
const stored = await getAllEvents();
|
||||||
setEvents(stored)
|
setEvents(stored);
|
||||||
})()
|
})();
|
||||||
}, [])
|
}, []);
|
||||||
|
|
||||||
const { data: session, isPending } = useSession()
|
const { data: session, isPending } = useSession();
|
||||||
|
|
||||||
const resetForm = () => {
|
const resetForm = () => {
|
||||||
setTitle('')
|
setTitle("");
|
||||||
setDescription('')
|
setDescription("");
|
||||||
setLocation('')
|
setLocation("");
|
||||||
setUrl('')
|
setUrl("");
|
||||||
setStart('')
|
setStart("");
|
||||||
setEnd('')
|
setEnd("");
|
||||||
setAllDay(false)
|
setAllDay(false);
|
||||||
setEditingId(null)
|
setEditingId(null);
|
||||||
setRecurrenceRule(undefined)
|
setRecurrenceRule(undefined);
|
||||||
}
|
};
|
||||||
|
|
||||||
const handleSave = async () => {
|
const handleSave = async () => {
|
||||||
const eventData: CalendarEvent = {
|
const eventData: CalendarEvent = {
|
||||||
id: editingId || nanoid(),
|
id: editingId || nanoid(),
|
||||||
title,
|
title,
|
||||||
description,
|
description,
|
||||||
location,
|
location,
|
||||||
url,
|
url,
|
||||||
recurrenceRule,
|
recurrenceRule,
|
||||||
start,
|
start,
|
||||||
end: end || undefined,
|
end: end || undefined,
|
||||||
allDay,
|
allDay,
|
||||||
createdAt: editingId
|
createdAt: editingId
|
||||||
? events.find(e => e.id === editingId)?.createdAt
|
? events.find((e) => e.id === editingId)?.createdAt
|
||||||
: new Date().toISOString(),
|
: new Date().toISOString(),
|
||||||
lastModified: new Date().toISOString(),
|
lastModified: new Date().toISOString(),
|
||||||
}
|
};
|
||||||
if (editingId) {
|
if (editingId) {
|
||||||
await updateEvent(eventData)
|
await updateEvent(eventData);
|
||||||
setEvents(prev => prev.map(e => (e.id === editingId ? eventData : e)))
|
setEvents((prev) =>
|
||||||
} else {
|
prev.map((e) => (e.id === editingId ? eventData : e)),
|
||||||
await addEvent(eventData)
|
);
|
||||||
setEvents(prev => [...prev, eventData])
|
} else {
|
||||||
}
|
await addEvent(eventData);
|
||||||
resetForm()
|
setEvents((prev) => [...prev, eventData]);
|
||||||
setDialogOpen(false)
|
}
|
||||||
}
|
resetForm();
|
||||||
|
setDialogOpen(false);
|
||||||
|
};
|
||||||
|
|
||||||
const handleDelete = async (id: string) => {
|
const handleDelete = async (id: string) => {
|
||||||
await deleteEvent(id)
|
await deleteEvent(id);
|
||||||
setEvents(prev => prev.filter(e => e.id !== id))
|
setEvents((prev) => prev.filter((e) => e.id !== id));
|
||||||
}
|
};
|
||||||
|
|
||||||
const handleClearAll = async () => {
|
const handleClearAll = async () => {
|
||||||
await clearEvents()
|
await clearEvents();
|
||||||
setEvents([])
|
setEvents([]);
|
||||||
}
|
};
|
||||||
|
|
||||||
const handleImport = async (file: File) => {
|
const handleImport = async (file: File) => {
|
||||||
const text = await file.text()
|
const text = await file.text();
|
||||||
const parsed = parseICS(text)
|
const parsed = parseICS(text);
|
||||||
for (const ev of parsed) {
|
for (const ev of parsed) {
|
||||||
await addEvent(ev)
|
await addEvent(ev);
|
||||||
}
|
}
|
||||||
const stored = await getAllEvents()
|
const stored = await getAllEvents();
|
||||||
setEvents(stored)
|
setEvents(stored);
|
||||||
}
|
};
|
||||||
|
|
||||||
const handleExport = () => {
|
const handleExport = () => {
|
||||||
const icsData = generateICS(events)
|
const icsData = generateICS(events);
|
||||||
const blob = new Blob([icsData], { type: 'text/calendar' })
|
const blob = new Blob([icsData], { type: "text/calendar" });
|
||||||
const url = URL.createObjectURL(blob)
|
const url = URL.createObjectURL(blob);
|
||||||
const a = document.createElement('a')
|
const a = document.createElement("a");
|
||||||
a.href = url
|
a.href = url;
|
||||||
a.download = `icallocal-export-${new Date().toLocaleTimeString()}.ics`
|
a.download = `icallocal-export-${new Date().toLocaleTimeString()}.ics`;
|
||||||
document.body.appendChild(a)
|
document.body.appendChild(a);
|
||||||
a.click()
|
a.click();
|
||||||
document.body.removeChild(a)
|
document.body.removeChild(a);
|
||||||
URL.revokeObjectURL(url)
|
URL.revokeObjectURL(url);
|
||||||
}
|
};
|
||||||
|
|
||||||
// AI Create Event
|
// AI Create Event
|
||||||
const handleAiCreate = async () => {
|
const handleAiCreate = async () => {
|
||||||
if (!aiPrompt.trim()) return
|
if (!aiPrompt.trim()) return;
|
||||||
setAiLoading(true)
|
setAiLoading(true);
|
||||||
|
|
||||||
const promise = (): Promise<{ message: string }> => new Promise(async (resolve, reject) => {
|
const promise = (): Promise<{ message: string }> =>
|
||||||
try {
|
new Promise(async (resolve, reject) => {
|
||||||
const res = await fetch('/api/ai-event', {
|
try {
|
||||||
method: 'POST',
|
const res = await fetch("/api/ai-event", {
|
||||||
headers: { 'Content-Type': 'application/json' },
|
method: "POST",
|
||||||
body: JSON.stringify({ prompt: aiPrompt })
|
headers: { "Content-Type": "application/json" },
|
||||||
})
|
body: JSON.stringify({ prompt: aiPrompt }),
|
||||||
|
});
|
||||||
|
|
||||||
if (res.status === 401) {
|
if (res.status === 401) {
|
||||||
setAiLoading(false)
|
setAiLoading(false);
|
||||||
reject({
|
reject({
|
||||||
message: 'Please sign in to use AI features.'
|
message: "Please sign in to use AI features.",
|
||||||
})
|
});
|
||||||
return
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const data = await res.json()
|
const data = await res.json();
|
||||||
|
|
||||||
if (Array.isArray(data) && data.length > 0) {
|
if (Array.isArray(data) && data.length > 0) {
|
||||||
if (data.length === 1) {
|
if (data.length === 1) {
|
||||||
// Prefill dialog directly (same as before)
|
// Prefill dialog directly (same as before)
|
||||||
const ev = data[0]
|
const ev = data[0];
|
||||||
setTitle(ev.title || '')
|
setTitle(ev.title || "");
|
||||||
setDescription(ev.description || '')
|
setDescription(ev.description || "");
|
||||||
setLocation(ev.location || '')
|
setLocation(ev.location || "");
|
||||||
setUrl(ev.url || '')
|
setUrl(ev.url || "");
|
||||||
setStart(ev.start || '')
|
setStart(ev.start || "");
|
||||||
setEnd(ev.end || '')
|
setEnd(ev.end || "");
|
||||||
setAllDay(ev.allDay || false)
|
setAllDay(ev.allDay || false);
|
||||||
setEditingId(null)
|
setEditingId(null);
|
||||||
setAiPrompt("")
|
setAiPrompt("");
|
||||||
setDialogOpen(true)
|
setDialogOpen(true);
|
||||||
setRecurrenceRule(ev.recurrenceRule || undefined)
|
setRecurrenceRule(ev.recurrenceRule || undefined);
|
||||||
resolve({
|
resolve({
|
||||||
message: 'Event has been created!'
|
message: "Event has been created!",
|
||||||
})
|
});
|
||||||
|
} else {
|
||||||
|
// Save them all directly to DB
|
||||||
|
for (const ev of data) {
|
||||||
|
const newEvent = {
|
||||||
|
id: nanoid(),
|
||||||
|
...ev,
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
lastModified: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
await addEvent(newEvent);
|
||||||
|
}
|
||||||
|
const stored = await getAllEvents();
|
||||||
|
setEvents(stored);
|
||||||
|
setAiPrompt("");
|
||||||
|
setSummary(`Added ${data.length} AI-generated events.`);
|
||||||
|
setSummaryUpdated(new Date().toLocaleString());
|
||||||
|
resolve({
|
||||||
|
message: "Event has been created!",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
reject({
|
||||||
|
message: "AI did not return event data.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
reject({
|
||||||
|
message: "Error from AI service.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
} else {
|
toast.promise(promise, {
|
||||||
// Save them all directly to DB
|
loading: "Generating event...",
|
||||||
for (const ev of data) {
|
success: ({ message }) => {
|
||||||
const newEvent = {
|
return message;
|
||||||
id: nanoid(),
|
},
|
||||||
...ev,
|
error: ({ message }) => {
|
||||||
createdAt: new Date().toISOString(),
|
return message;
|
||||||
lastModified: new Date().toISOString(),
|
},
|
||||||
}
|
});
|
||||||
await addEvent(newEvent)
|
|
||||||
}
|
|
||||||
const stored = await getAllEvents()
|
|
||||||
setEvents(stored)
|
|
||||||
setAiPrompt("")
|
|
||||||
setSummary(`Added ${data.length} AI-generated events.`)
|
|
||||||
setSummaryUpdated(new Date().toLocaleString())
|
|
||||||
resolve({
|
|
||||||
message: 'Event has been created!'
|
|
||||||
})
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
reject({
|
|
||||||
message: 'AI did not return event data.'
|
|
||||||
})
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
console.error(err)
|
|
||||||
reject({
|
|
||||||
message: 'Error from AI service.'
|
|
||||||
})
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
toast.promise(promise, {
|
setAiLoading(false);
|
||||||
loading: "Generating event...",
|
};
|
||||||
success: ({ message }) => {
|
|
||||||
return message
|
|
||||||
},
|
|
||||||
error: ({ message }) => {
|
|
||||||
return message
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
setAiLoading(false)
|
// AI Summarize Events
|
||||||
}
|
const handleAiSummarize = async () => {
|
||||||
|
if (!events.length) {
|
||||||
|
setSummary("No events to summarize.");
|
||||||
|
setSummaryUpdated(new Date().toLocaleString());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setAiLoading(true);
|
||||||
|
try {
|
||||||
|
const res = await fetch("/api/ai-summary", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ events }),
|
||||||
|
});
|
||||||
|
const data = await res.json();
|
||||||
|
if (data.summary) {
|
||||||
|
setSummary(data.summary);
|
||||||
|
setSummaryUpdated(new Date().toLocaleString());
|
||||||
|
} else {
|
||||||
|
setSummary("No summary generated.");
|
||||||
|
setSummaryUpdated(new Date().toLocaleString());
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
setSummary("Error summarizing events");
|
||||||
|
setSummaryUpdated(new Date().toLocaleString());
|
||||||
|
} finally {
|
||||||
|
setAiLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
// AI Summarize Events
|
const handleEdit = (eventData: CalendarEvent) => {
|
||||||
const handleAiSummarize = async () => {
|
setTitle(eventData.title);
|
||||||
if (!events.length) {
|
setDescription(eventData.description || "");
|
||||||
setSummary("No events to summarize.")
|
setLocation(eventData.location || "");
|
||||||
setSummaryUpdated(new Date().toLocaleString())
|
setUrl(eventData.url || "");
|
||||||
return
|
setStart(eventData.start);
|
||||||
}
|
setEnd(eventData.end || "");
|
||||||
setAiLoading(true)
|
setAllDay(eventData.allDay || false);
|
||||||
try {
|
setEditingId(eventData.id);
|
||||||
const res = await fetch('/api/ai-summary', {
|
setRecurrenceRule(eventData.recurrenceRule);
|
||||||
method: 'POST',
|
setDialogOpen(true);
|
||||||
headers: { 'Content-Type': 'application/json' },
|
};
|
||||||
body: JSON.stringify({ events })
|
|
||||||
})
|
|
||||||
const data = await res.json()
|
|
||||||
if (data.summary) {
|
|
||||||
setSummary(data.summary)
|
|
||||||
setSummaryUpdated(new Date().toLocaleString())
|
|
||||||
} else {
|
|
||||||
setSummary("No summary generated.")
|
|
||||||
setSummaryUpdated(new Date().toLocaleString())
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
setSummary("Error summarizing events")
|
|
||||||
setSummaryUpdated(new Date().toLocaleString())
|
|
||||||
} finally {
|
|
||||||
setAiLoading(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleEdit = (eventData: CalendarEvent) => {
|
return (
|
||||||
setTitle(eventData.title)
|
<DragDropContainer
|
||||||
setDescription(eventData.description || "")
|
isDragOver={isDragOver}
|
||||||
setLocation(eventData.location || "")
|
setIsDragOver={setIsDragOver}
|
||||||
setUrl(eventData.url || "")
|
onImport={handleImport}
|
||||||
setStart(eventData.start)
|
>
|
||||||
setEnd(eventData.end || "")
|
<AIToolbar
|
||||||
setAllDay(eventData.allDay || false)
|
isAuthenticated={!!session?.user}
|
||||||
setEditingId(eventData.id)
|
isPending={isPending}
|
||||||
setRecurrenceRule(eventData.recurrenceRule)
|
aiPrompt={aiPrompt}
|
||||||
setDialogOpen(true)
|
setAiPrompt={setAiPrompt}
|
||||||
}
|
aiLoading={aiLoading}
|
||||||
|
onAiCreate={handleAiCreate}
|
||||||
|
onAiSummarize={handleAiSummarize}
|
||||||
|
summary={summary}
|
||||||
|
summaryUpdated={summaryUpdated}
|
||||||
|
/>
|
||||||
|
|
||||||
return (
|
<EventActionsToolbar
|
||||||
<DragDropContainer
|
events={events}
|
||||||
isDragOver={isDragOver}
|
onAddEvent={() => setDialogOpen(true)}
|
||||||
setIsDragOver={setIsDragOver}
|
onImport={handleImport}
|
||||||
onImport={handleImport}
|
onExport={handleExport}
|
||||||
>
|
onClearAll={handleClearAll}
|
||||||
<AIToolbar
|
/>
|
||||||
isAuthenticated={!!session?.user}
|
|
||||||
isPending={isPending}
|
|
||||||
aiPrompt={aiPrompt}
|
|
||||||
setAiPrompt={setAiPrompt}
|
|
||||||
aiLoading={aiLoading}
|
|
||||||
onAiCreate={handleAiCreate}
|
|
||||||
onAiSummarize={handleAiSummarize}
|
|
||||||
summary={summary}
|
|
||||||
summaryUpdated={summaryUpdated}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<EventActionsToolbar
|
<EventsList events={events} onEdit={handleEdit} onDelete={handleDelete} />
|
||||||
events={events}
|
|
||||||
onAddEvent={() => setDialogOpen(true)}
|
|
||||||
onImport={handleImport}
|
|
||||||
onExport={handleExport}
|
|
||||||
onClearAll={handleClearAll}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<EventsList
|
<EventDialog
|
||||||
events={events}
|
open={dialogOpen}
|
||||||
onEdit={handleEdit}
|
onOpenChange={setDialogOpen}
|
||||||
onDelete={handleDelete}
|
editingId={editingId}
|
||||||
/>
|
title={title}
|
||||||
|
setTitle={setTitle}
|
||||||
<EventDialog
|
description={description}
|
||||||
open={dialogOpen}
|
setDescription={setDescription}
|
||||||
onOpenChange={setDialogOpen}
|
location={location}
|
||||||
editingId={editingId}
|
setLocation={setLocation}
|
||||||
title={title}
|
url={url}
|
||||||
setTitle={setTitle}
|
setUrl={setUrl}
|
||||||
description={description}
|
start={start}
|
||||||
setDescription={setDescription}
|
setStart={setStart}
|
||||||
location={location}
|
end={end}
|
||||||
setLocation={setLocation}
|
setEnd={setEnd}
|
||||||
url={url}
|
allDay={allDay}
|
||||||
setUrl={setUrl}
|
setAllDay={setAllDay}
|
||||||
start={start}
|
recurrenceRule={recurrenceRule}
|
||||||
setStart={setStart}
|
setRecurrenceRule={setRecurrenceRule}
|
||||||
end={end}
|
onSave={handleSave}
|
||||||
setEnd={setEnd}
|
onReset={resetForm}
|
||||||
allDay={allDay}
|
/>
|
||||||
setAllDay={setAllDay}
|
</DragDropContainer>
|
||||||
recurrenceRule={recurrenceRule}
|
);
|
||||||
setRecurrenceRule={setRecurrenceRule}
|
|
||||||
onSave={handleSave}
|
|
||||||
onReset={resetForm}
|
|
||||||
/>
|
|
||||||
</DragDropContainer>
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|||||||
12
src/auth.ts
12
src/auth.ts
@@ -6,23 +6,23 @@ import * as schema from "@/db/schema";
|
|||||||
|
|
||||||
// Validate required environment variables
|
// Validate required environment variables
|
||||||
if (!process.env.BETTER_AUTH_SECRET) {
|
if (!process.env.BETTER_AUTH_SECRET) {
|
||||||
throw new Error("BETTER_AUTH_SECRET is required");
|
throw new Error("BETTER_AUTH_SECRET is required");
|
||||||
}
|
}
|
||||||
if (!process.env.BETTER_AUTH_URL) {
|
if (!process.env.BETTER_AUTH_URL) {
|
||||||
throw new Error("BETTER_AUTH_URL is required");
|
throw new Error("BETTER_AUTH_URL is required");
|
||||||
}
|
}
|
||||||
if (!process.env.AUTH_AUTHENTIK_CLIENT_ID) {
|
if (!process.env.AUTH_AUTHENTIK_CLIENT_ID) {
|
||||||
throw new Error("AUTH_AUTHENTIK_CLIENT_ID is required");
|
throw new Error("AUTH_AUTHENTIK_CLIENT_ID is required");
|
||||||
}
|
}
|
||||||
if (!process.env.AUTH_AUTHENTIK_CLIENT_SECRET) {
|
if (!process.env.AUTH_AUTHENTIK_CLIENT_SECRET) {
|
||||||
throw new Error("AUTH_AUTHENTIK_CLIENT_SECRET is required");
|
throw new Error("AUTH_AUTHENTIK_CLIENT_SECRET is required");
|
||||||
}
|
}
|
||||||
if (!process.env.AUTH_AUTHENTIK_ISSUER) {
|
if (!process.env.AUTH_AUTHENTIK_ISSUER) {
|
||||||
throw new Error("AUTH_AUTHENTIK_ISSUER is required");
|
throw new Error("AUTH_AUTHENTIK_ISSUER is required");
|
||||||
}
|
}
|
||||||
|
|
||||||
export const auth = betterAuth({
|
export const auth = betterAuth({
|
||||||
secret: process.env.BETTER_AUTH_SECRET,
|
secret: process.env.BETTER_AUTH_SECRET,
|
||||||
baseURL: process.env.BETTER_AUTH_URL,
|
baseURL: process.env.BETTER_AUTH_URL,
|
||||||
trustedOrigins: [process.env.BETTER_AUTH_URL],
|
trustedOrigins: [process.env.BETTER_AUTH_URL],
|
||||||
database: drizzleAdapter(db, {
|
database: drizzleAdapter(db, {
|
||||||
|
|||||||
@@ -1,80 +1,84 @@
|
|||||||
import { Button } from '@/components/ui/button'
|
import { Button } from "@/components/ui/button";
|
||||||
import { Textarea } from '@/components/ui/textarea'
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
import { Card } from '@/components/ui/card'
|
import { Card } from "@/components/ui/card";
|
||||||
|
|
||||||
interface AIToolbarProps {
|
interface AIToolbarProps {
|
||||||
isAuthenticated: boolean
|
isAuthenticated: boolean;
|
||||||
isPending: boolean
|
isPending: boolean;
|
||||||
aiPrompt: string
|
aiPrompt: string;
|
||||||
setAiPrompt: (prompt: string) => void
|
setAiPrompt: (prompt: string) => void;
|
||||||
aiLoading: boolean
|
aiLoading: boolean;
|
||||||
onAiCreate: () => void
|
onAiCreate: () => void;
|
||||||
onAiSummarize: () => void
|
onAiSummarize: () => void;
|
||||||
summary: string | null
|
summary: string | null;
|
||||||
summaryUpdated: string | null
|
summaryUpdated: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const AIToolbar = ({
|
export const AIToolbar = ({
|
||||||
isAuthenticated,
|
isAuthenticated,
|
||||||
isPending,
|
isPending,
|
||||||
aiPrompt,
|
aiPrompt,
|
||||||
setAiPrompt,
|
setAiPrompt,
|
||||||
aiLoading,
|
aiLoading,
|
||||||
onAiCreate,
|
onAiCreate,
|
||||||
onAiSummarize,
|
onAiSummarize,
|
||||||
summary,
|
summary,
|
||||||
summaryUpdated
|
summaryUpdated,
|
||||||
}: AIToolbarProps) => {
|
}: AIToolbarProps) => {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{isPending ? (
|
{isPending ? (
|
||||||
<div className='mb-4 p-4 text-center animate-pulse bg-muted'>Loading...</div>
|
<div className="mb-4 p-4 text-center animate-pulse bg-muted">
|
||||||
) : (
|
Loading...
|
||||||
<div>
|
</div>
|
||||||
{isAuthenticated ? (
|
) : (
|
||||||
<div className="flex flex-col sm:flex-row gap-4 mb-4 items-start">
|
<div>
|
||||||
<div className='w-full'>
|
{isAuthenticated ? (
|
||||||
<Textarea
|
<div className="flex flex-col sm:flex-row gap-4 mb-4 items-start">
|
||||||
className="wrap-anywhere field-sizing-content resize-none w-full min-h-[2.5rem] max-h-64 overflow-y-auto sm:overflow-y-visible px-3 py-2 scroll-p-8 placeholder:italic"
|
<div className="w-full">
|
||||||
style={{ clipPath: "inset(0 round 1rem)" }}
|
<Textarea
|
||||||
placeholder='Describe event for AI to create'
|
className="wrap-anywhere field-sizing-content resize-none w-full min-h-[2.5rem] max-h-64 overflow-y-auto sm:overflow-y-visible px-3 py-2 scroll-p-8 placeholder:italic"
|
||||||
value={aiPrompt}
|
style={{ clipPath: "inset(0 round 1rem)" }}
|
||||||
onChange={e => setAiPrompt(e.target.value)}
|
placeholder="Describe event for AI to create"
|
||||||
/>
|
value={aiPrompt}
|
||||||
</div>
|
onChange={(e) => setAiPrompt(e.target.value)}
|
||||||
<div className='flex flex-row gap-2 pt-1'>
|
/>
|
||||||
<Button onClick={onAiCreate} disabled={aiLoading}>
|
</div>
|
||||||
{aiLoading ? 'Thinking...' : 'AI Create'}
|
<div className="flex flex-row gap-2 pt-1">
|
||||||
</Button>
|
<Button onClick={onAiCreate} disabled={aiLoading}>
|
||||||
</div>
|
{aiLoading ? "Thinking..." : "AI Create"}
|
||||||
</div>
|
</Button>
|
||||||
) : (
|
</div>
|
||||||
<div className="mb-4 p-4 border border-dashed rounded-lg text-center">
|
</div>
|
||||||
<div className="text-sm text-muted-foreground">
|
) : (
|
||||||
Sign in to unlock natural language event creation powered by AI
|
<div className="mb-4 p-4 border border-dashed rounded-lg text-center">
|
||||||
</div>
|
<div className="text-sm text-muted-foreground">
|
||||||
</div>
|
Sign in to unlock natural language event creation powered by AI
|
||||||
)}
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Summary Panel */}
|
{/* Summary Panel */}
|
||||||
{summary && (
|
{summary && (
|
||||||
<Card className="p-4 mb-4">
|
<Card className="p-4 mb-4">
|
||||||
<div className="text-sm mb-1">
|
<div className="text-sm mb-1">Summary updated {summaryUpdated}</div>
|
||||||
Summary updated {summaryUpdated}
|
<div>{summary}</div>
|
||||||
</div>
|
</Card>
|
||||||
<div>{summary}</div>
|
)}
|
||||||
</Card>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* AI Actions Toolbar */}
|
{/* AI Actions Toolbar */}
|
||||||
<p className='text-muted-foreground text-sm pb-2 pl-1'>AI actions</p>
|
<p className="text-muted-foreground text-sm pb-2 pl-1">AI actions</p>
|
||||||
<div className="gap-2 mb-4">
|
<div className="gap-2 mb-4">
|
||||||
<Button variant="secondary" onClick={onAiSummarize} disabled={aiLoading}>
|
<Button
|
||||||
{aiLoading ? 'Summarizing...' : 'AI Summarize'}
|
variant="secondary"
|
||||||
</Button>
|
onClick={onAiSummarize}
|
||||||
</div>
|
disabled={aiLoading}
|
||||||
</>
|
>
|
||||||
)
|
{aiLoading ? "Summarizing..." : "AI Summarize"}
|
||||||
}
|
</Button>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|||||||
@@ -1,55 +1,55 @@
|
|||||||
import { ReactNode } from 'react'
|
import { ReactNode } from "react";
|
||||||
import { toast } from 'sonner'
|
import { toast } from "sonner";
|
||||||
|
|
||||||
interface DragDropContainerProps {
|
interface DragDropContainerProps {
|
||||||
children: ReactNode
|
children: ReactNode;
|
||||||
isDragOver: boolean
|
isDragOver: boolean;
|
||||||
setIsDragOver: (isDragOver: boolean) => void
|
setIsDragOver: (isDragOver: boolean) => void;
|
||||||
onImport: (file: File) => void
|
onImport: (file: File) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const DragDropContainer = ({
|
export const DragDropContainer = ({
|
||||||
children,
|
children,
|
||||||
isDragOver,
|
isDragOver,
|
||||||
setIsDragOver,
|
setIsDragOver,
|
||||||
onImport
|
onImport,
|
||||||
}: DragDropContainerProps) => {
|
}: DragDropContainerProps) => {
|
||||||
const handleDragOver = (e: React.DragEvent) => {
|
const handleDragOver = (e: React.DragEvent) => {
|
||||||
e.preventDefault()
|
e.preventDefault();
|
||||||
setIsDragOver(true)
|
setIsDragOver(true);
|
||||||
}
|
};
|
||||||
|
|
||||||
const handleDragLeave = (e: React.DragEvent) => {
|
const handleDragLeave = (e: React.DragEvent) => {
|
||||||
e.preventDefault()
|
e.preventDefault();
|
||||||
setIsDragOver(false)
|
setIsDragOver(false);
|
||||||
}
|
};
|
||||||
|
|
||||||
const handleDrop = (e: React.DragEvent) => {
|
const handleDrop = (e: React.DragEvent) => {
|
||||||
e.preventDefault()
|
e.preventDefault();
|
||||||
setIsDragOver(false)
|
setIsDragOver(false);
|
||||||
if (e.dataTransfer.files?.length) {
|
if (e.dataTransfer.files?.length) {
|
||||||
const file = e.dataTransfer.files[0]
|
const file = e.dataTransfer.files[0];
|
||||||
if (file.name.endsWith('.ics')) {
|
if (file.name.endsWith(".ics")) {
|
||||||
onImport(file)
|
onImport(file);
|
||||||
} else {
|
} else {
|
||||||
toast.warning('Please drop an .ics file')
|
toast.warning("Please drop an .ics file");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
onDragOver={handleDragOver}
|
onDragOver={handleDragOver}
|
||||||
onDragLeave={handleDragLeave}
|
onDragLeave={handleDragLeave}
|
||||||
onDrop={handleDrop}
|
onDrop={handleDrop}
|
||||||
className={`p-4 min-h-[80vh] flex flex-col rounded border-2 border-dashed transition ${
|
className={`p-4 min-h-[80vh] flex flex-col rounded border-2 border-dashed transition ${
|
||||||
isDragOver ? 'border-blue-500 bg-blue-50' : 'border-gray-700'
|
isDragOver ? "border-blue-500 bg-blue-50" : "border-gray-700"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
<div className='mt-auto w-full pb-4 text-gray-400'>
|
<div className="mt-auto w-full pb-4 text-gray-400">
|
||||||
<div className='max-w-fit m-auto'>Drag & Drop *.ics here</div>
|
<div className="max-w-fit m-auto">Drag & Drop *.ics here</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
);
|
||||||
}
|
};
|
||||||
|
|||||||
@@ -1,36 +1,42 @@
|
|||||||
import { Button } from '@/components/ui/button'
|
import { Button } from "@/components/ui/button";
|
||||||
import { IcsFilePicker } from '@/components/ics-file-picker'
|
import { IcsFilePicker } from "@/components/ics-file-picker";
|
||||||
import type { CalendarEvent } from '@/lib/types'
|
import type { CalendarEvent } from "@/lib/types";
|
||||||
|
|
||||||
interface EventActionsToolbarProps {
|
interface EventActionsToolbarProps {
|
||||||
events: CalendarEvent[]
|
events: CalendarEvent[];
|
||||||
onAddEvent: () => void
|
onAddEvent: () => void;
|
||||||
onImport: (file: File) => void
|
onImport: (file: File) => void;
|
||||||
onExport: () => void
|
onExport: () => void;
|
||||||
onClearAll: () => void
|
onClearAll: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const EventActionsToolbar = ({
|
export const EventActionsToolbar = ({
|
||||||
events,
|
events,
|
||||||
onAddEvent,
|
onAddEvent,
|
||||||
onImport,
|
onImport,
|
||||||
onExport,
|
onExport,
|
||||||
onClearAll
|
onClearAll,
|
||||||
}: EventActionsToolbarProps) => {
|
}: EventActionsToolbarProps) => {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{/* Control Toolbar */}
|
{/* Control Toolbar */}
|
||||||
<p className='text-muted-foreground text-sm pb-2 pl-1'>Event Actions</p>
|
<p className="text-muted-foreground text-sm pb-2 pl-1">Event Actions</p>
|
||||||
<div className="flex flex-wrap gap-2 mb-4">
|
<div className="flex flex-wrap gap-2 mb-4">
|
||||||
<Button onClick={onAddEvent}>Add Event</Button>
|
<Button onClick={onAddEvent}>Add Event</Button>
|
||||||
<IcsFilePicker onFileSelect={onImport} variant='secondary'>Import .ics</IcsFilePicker>
|
<IcsFilePicker onFileSelect={onImport} variant="secondary">
|
||||||
{events.length > 0 && (
|
Import .ics
|
||||||
<>
|
</IcsFilePicker>
|
||||||
<Button variant="secondary" onClick={onExport}>Export .ics</Button>
|
{events.length > 0 && (
|
||||||
<Button variant="destructive" onClick={onClearAll}>Clear All</Button>
|
<>
|
||||||
</>
|
<Button variant="secondary" onClick={onExport}>
|
||||||
)}
|
Export .ics
|
||||||
</div>
|
</Button>
|
||||||
</>
|
<Button variant="destructive" onClick={onClearAll}>
|
||||||
)
|
Clear All
|
||||||
}
|
</Button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|||||||
@@ -1,92 +1,95 @@
|
|||||||
import { Button } from '@/components/ui/button'
|
import { Button } from "@/components/ui/button";
|
||||||
import { Card, CardHeader, CardContent } from '@/components/ui/card'
|
import { Card, CardHeader, CardContent } from "@/components/ui/card";
|
||||||
import { LucideMapPin, Clock, MoreHorizontal } from 'lucide-react'
|
import { LucideMapPin, Clock, MoreHorizontal } from "lucide-react";
|
||||||
import { DropdownMenu, DropdownMenuContent, DropdownMenuTrigger, DropdownMenuItem } from '@/components/ui/dropdown-menu'
|
import {
|
||||||
import { RRuleDisplay } from '@/components/rrule-display'
|
DropdownMenu,
|
||||||
import type { CalendarEvent } from '@/lib/types'
|
DropdownMenuContent,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
DropdownMenuItem,
|
||||||
|
} from "@/components/ui/dropdown-menu";
|
||||||
|
import { RRuleDisplay } from "@/components/rrule-display";
|
||||||
|
import type { CalendarEvent } from "@/lib/types";
|
||||||
|
|
||||||
interface EventCardProps {
|
interface EventCardProps {
|
||||||
event: CalendarEvent
|
event: CalendarEvent;
|
||||||
onEdit: (event: CalendarEvent) => void
|
onEdit: (event: CalendarEvent) => void;
|
||||||
onDelete: (eventId: string) => void
|
onDelete: (eventId: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const EventCard = ({ event, onEdit, onDelete }: EventCardProps) => {
|
export const EventCard = ({ event, onEdit, onDelete }: EventCardProps) => {
|
||||||
const formatDateTime = (dateStr: string, allDay: boolean | undefined) => {
|
const formatDateTime = (dateStr: string, allDay: boolean | undefined) => {
|
||||||
return allDay
|
return allDay
|
||||||
? new Date(dateStr).toLocaleDateString()
|
? new Date(dateStr).toLocaleDateString()
|
||||||
: new Date(dateStr).toLocaleString()
|
: new Date(dateStr).toLocaleString();
|
||||||
}
|
};
|
||||||
|
|
||||||
const handleEdit = () => {
|
const handleEdit = () => {
|
||||||
onEdit({
|
onEdit({
|
||||||
id: event.id,
|
id: event.id,
|
||||||
title: event.title,
|
title: event.title,
|
||||||
description: event.description || '',
|
description: event.description || "",
|
||||||
location: event.location || '',
|
location: event.location || "",
|
||||||
url: event.url || '',
|
url: event.url || "",
|
||||||
start: event.start,
|
start: event.start,
|
||||||
end: event.end || '',
|
end: event.end || "",
|
||||||
allDay: event.allDay || false
|
allDay: event.allDay || false,
|
||||||
})
|
});
|
||||||
}
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card className="w-full">
|
<Card className="w-full">
|
||||||
<CardHeader className="pb-3">
|
<CardHeader className="pb-3">
|
||||||
<div className="flex items-start justify-between">
|
<div className="flex items-start justify-between">
|
||||||
<div className="space-y-1 flex-1">
|
<div className="space-y-1 flex-1">
|
||||||
<h3 className="font-semibold leading-none tracking-tight">
|
<h3 className="font-semibold leading-none tracking-tight">
|
||||||
{event.title}
|
{event.title}
|
||||||
</h3>
|
</h3>
|
||||||
{event.recurrenceRule && (
|
{event.recurrenceRule && (
|
||||||
<div className="mt-1">
|
<div className="mt-1">
|
||||||
<RRuleDisplay rrule={event.recurrenceRule} />
|
<RRuleDisplay rrule={event.recurrenceRule} />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{event.description && (
|
{event.description && (
|
||||||
<p className="text-sm text-muted-foreground mt-2 break-words">
|
<p className="text-sm text-muted-foreground mt-2 break-words">
|
||||||
{event.description}
|
{event.description}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<DropdownMenu>
|
<DropdownMenu>
|
||||||
<DropdownMenuTrigger asChild>
|
<DropdownMenuTrigger asChild>
|
||||||
<Button variant="ghost" className="h-8 w-8 p-0">
|
<Button variant="ghost" className="h-8 w-8 p-0">
|
||||||
<MoreHorizontal className="h-4 w-4" />
|
<MoreHorizontal className="h-4 w-4" />
|
||||||
<span className="sr-only">Open menu</span>
|
<span className="sr-only">Open menu</span>
|
||||||
</Button>
|
</Button>
|
||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
<DropdownMenuContent align="end">
|
<DropdownMenuContent align="end">
|
||||||
<DropdownMenuItem onClick={handleEdit}>
|
<DropdownMenuItem onClick={handleEdit}>Edit</DropdownMenuItem>
|
||||||
Edit
|
<DropdownMenuItem
|
||||||
</DropdownMenuItem>
|
onClick={() => onDelete(event.id)}
|
||||||
<DropdownMenuItem
|
className="text-destructive"
|
||||||
onClick={() => onDelete(event.id)}
|
>
|
||||||
className="text-destructive"
|
Delete
|
||||||
>
|
</DropdownMenuItem>
|
||||||
Delete
|
</DropdownMenuContent>
|
||||||
</DropdownMenuItem>
|
</DropdownMenu>
|
||||||
</DropdownMenuContent>
|
</div>
|
||||||
</DropdownMenu>
|
</CardHeader>
|
||||||
</div>
|
|
||||||
</CardHeader>
|
|
||||||
|
|
||||||
<CardContent className="pt-0">
|
<CardContent className="pt-0">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<div className="flex items-center text-sm text-muted-foreground">
|
<div className="flex items-center text-sm text-muted-foreground">
|
||||||
<Clock className="mr-2 h-4 w-4" />
|
<Clock className="mr-2 h-4 w-4" />
|
||||||
{formatDateTime(event.start, event.allDay)}
|
{formatDateTime(event.start, event.allDay)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{event.location && (
|
{event.location && (
|
||||||
<div className="flex items-center text-sm text-muted-foreground">
|
<div className="flex items-center text-sm text-muted-foreground">
|
||||||
<LucideMapPin className="mr-2 h-4 w-4" />
|
<LucideMapPin className="mr-2 h-4 w-4" />
|
||||||
{event.location}
|
{event.location}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
)
|
);
|
||||||
}
|
};
|
||||||
|
|||||||
@@ -1,96 +1,134 @@
|
|||||||
import { Button } from '@/components/ui/button'
|
import { Button } from "@/components/ui/button";
|
||||||
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog'
|
import {
|
||||||
import { Input } from '@/components/ui/input'
|
Dialog,
|
||||||
import { RecurrencePicker } from '@/components/recurrence-picker'
|
DialogContent,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { RecurrencePicker } from "@/components/recurrence-picker";
|
||||||
|
|
||||||
interface EventDialogProps {
|
interface EventDialogProps {
|
||||||
open: boolean
|
open: boolean;
|
||||||
onOpenChange: (open: boolean) => void
|
onOpenChange: (open: boolean) => void;
|
||||||
editingId: string | null
|
editingId: string | null;
|
||||||
title: string
|
title: string;
|
||||||
setTitle: (title: string) => void
|
setTitle: (title: string) => void;
|
||||||
description: string
|
description: string;
|
||||||
setDescription: (description: string) => void
|
setDescription: (description: string) => void;
|
||||||
location: string
|
location: string;
|
||||||
setLocation: (location: string) => void
|
setLocation: (location: string) => void;
|
||||||
url: string
|
url: string;
|
||||||
setUrl: (url: string) => void
|
setUrl: (url: string) => void;
|
||||||
start: string
|
start: string;
|
||||||
setStart: (start: string) => void
|
setStart: (start: string) => void;
|
||||||
end: string
|
end: string;
|
||||||
setEnd: (end: string) => void
|
setEnd: (end: string) => void;
|
||||||
allDay: boolean
|
allDay: boolean;
|
||||||
setAllDay: (allDay: boolean) => void
|
setAllDay: (allDay: boolean) => void;
|
||||||
recurrenceRule: string | undefined
|
recurrenceRule: string | undefined;
|
||||||
setRecurrenceRule: (rule: string | undefined) => void
|
setRecurrenceRule: (rule: string | undefined) => void;
|
||||||
onSave: () => void
|
onSave: () => void;
|
||||||
onReset: () => void
|
onReset: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const EventDialog = ({
|
export const EventDialog = ({
|
||||||
open,
|
open,
|
||||||
onOpenChange,
|
onOpenChange,
|
||||||
editingId,
|
editingId,
|
||||||
title,
|
title,
|
||||||
setTitle,
|
setTitle,
|
||||||
description,
|
description,
|
||||||
setDescription,
|
setDescription,
|
||||||
location,
|
location,
|
||||||
setLocation,
|
setLocation,
|
||||||
url,
|
url,
|
||||||
setUrl,
|
setUrl,
|
||||||
start,
|
start,
|
||||||
setStart,
|
setStart,
|
||||||
end,
|
end,
|
||||||
setEnd,
|
setEnd,
|
||||||
allDay,
|
allDay,
|
||||||
setAllDay,
|
setAllDay,
|
||||||
recurrenceRule,
|
recurrenceRule,
|
||||||
setRecurrenceRule,
|
setRecurrenceRule,
|
||||||
onSave,
|
onSave,
|
||||||
onReset
|
onReset,
|
||||||
}: EventDialogProps) => {
|
}: EventDialogProps) => {
|
||||||
const handleOpenChange = (val: boolean) => {
|
const handleOpenChange = (val: boolean) => {
|
||||||
if (!val) onReset()
|
if (!val) onReset();
|
||||||
onOpenChange(val)
|
onOpenChange(val);
|
||||||
}
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={open} onOpenChange={handleOpenChange}>
|
<Dialog open={open} onOpenChange={handleOpenChange}>
|
||||||
<DialogContent>
|
<DialogContent>
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>{editingId ? 'Edit Event' : 'Add Event'}</DialogTitle>
|
<DialogTitle>{editingId ? "Edit Event" : "Add Event"}</DialogTitle>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<Input placeholder="Title" value={title} onChange={e => setTitle(e.target.value)} />
|
<Input
|
||||||
<textarea
|
placeholder="Title"
|
||||||
className="border rounded p-2 w-full"
|
value={title}
|
||||||
placeholder="Description"
|
onChange={(e) => setTitle(e.target.value)}
|
||||||
value={description}
|
/>
|
||||||
onChange={e => setDescription(e.target.value)}
|
<textarea
|
||||||
/>
|
className="border rounded p-2 w-full"
|
||||||
<Input placeholder="Location" value={location} onChange={e => setLocation(e.target.value)} />
|
placeholder="Description"
|
||||||
<Input placeholder="URL" value={url} onChange={e => setUrl(e.target.value)} />
|
value={description}
|
||||||
<RecurrencePicker value={recurrenceRule} onChange={setRecurrenceRule} />
|
onChange={(e) => setDescription(e.target.value)}
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
placeholder="Location"
|
||||||
|
value={location}
|
||||||
|
onChange={(e) => setLocation(e.target.value)}
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
placeholder="URL"
|
||||||
|
value={url}
|
||||||
|
onChange={(e) => setUrl(e.target.value)}
|
||||||
|
/>
|
||||||
|
<RecurrencePicker value={recurrenceRule} onChange={setRecurrenceRule} />
|
||||||
|
|
||||||
<label className="flex items-center gap-2 mt-2">
|
<label className="flex items-center gap-2 mt-2">
|
||||||
<input type="checkbox" checked={allDay} onChange={e => setAllDay(e.target.checked)} />
|
<input
|
||||||
All day event
|
type="checkbox"
|
||||||
</label>
|
checked={allDay}
|
||||||
{!allDay ? (
|
onChange={(e) => setAllDay(e.target.checked)}
|
||||||
<>
|
/>
|
||||||
<Input type="datetime-local" value={start} onChange={e => setStart(e.target.value)} />
|
All day event
|
||||||
<Input type="datetime-local" value={end} onChange={e => setEnd(e.target.value)} />
|
</label>
|
||||||
</>
|
{!allDay ? (
|
||||||
) : (
|
<>
|
||||||
<>
|
<Input
|
||||||
<Input type="date" value={start ? start.split('T')[0] : ''} onChange={e => setStart(e.target.value)} />
|
type="datetime-local"
|
||||||
<Input type="date" value={end ? end.split('T')[0] : ''} onChange={e => setEnd(e.target.value)} />
|
value={start}
|
||||||
</>
|
onChange={(e) => setStart(e.target.value)}
|
||||||
)}
|
/>
|
||||||
<DialogFooter>
|
<Input
|
||||||
<Button onClick={onSave}>{editingId ? 'Update' : 'Save'}</Button>
|
type="datetime-local"
|
||||||
</DialogFooter>
|
value={end}
|
||||||
</DialogContent>
|
onChange={(e) => setEnd(e.target.value)}
|
||||||
</Dialog>
|
/>
|
||||||
)
|
</>
|
||||||
}
|
) : (
|
||||||
|
<>
|
||||||
|
<Input
|
||||||
|
type="date"
|
||||||
|
value={start ? start.split("T")[0] : ""}
|
||||||
|
onChange={(e) => setStart(e.target.value)}
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
type="date"
|
||||||
|
value={end ? end.split("T")[0] : ""}
|
||||||
|
onChange={(e) => setEnd(e.target.value)}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<DialogFooter>
|
||||||
|
<Button onClick={onSave}>{editingId ? "Update" : "Save"}</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|||||||
@@ -1,34 +1,38 @@
|
|||||||
import { Calendar1Icon } from 'lucide-react'
|
import { Calendar1Icon } from "lucide-react";
|
||||||
import { EventCard } from './event-card'
|
import { EventCard } from "./event-card";
|
||||||
import type { CalendarEvent } from '@/lib/types'
|
import type { CalendarEvent } from "@/lib/types";
|
||||||
|
|
||||||
interface EventsListProps {
|
interface EventsListProps {
|
||||||
events: CalendarEvent[]
|
events: CalendarEvent[];
|
||||||
onEdit: (event: CalendarEvent) => void
|
onEdit: (event: CalendarEvent) => void;
|
||||||
onDelete: (eventId: string) => void
|
onDelete: (eventId: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const EventsList = ({ events, onEdit, onDelete }: EventsListProps) => {
|
export const EventsList = ({ events, onEdit, onDelete }: EventsListProps) => {
|
||||||
if (events.length === 0) {
|
if (events.length === 0) {
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col items-center justify-center py-8 text-center">
|
<div className="flex flex-col items-center justify-center py-8 text-center">
|
||||||
<Calendar1Icon className='h-12 w-12 text-muted-foreground mb-4' />
|
<Calendar1Icon className="h-12 w-12 text-muted-foreground mb-4" />
|
||||||
<h3 className="text-lg font-medium text-muted-foreground">No events yet</h3>
|
<h3 className="text-lg font-medium text-muted-foreground">
|
||||||
<p className="text-sm text-muted-foreground">Create your first event to get started</p>
|
No events yet
|
||||||
</div>
|
</h3>
|
||||||
)
|
<p className="text-sm text-muted-foreground">
|
||||||
}
|
Create your first event to get started
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
{events.map(event => (
|
{events.map((event) => (
|
||||||
<EventCard
|
<EventCard
|
||||||
key={event.id}
|
key={event.id}
|
||||||
event={event}
|
event={event}
|
||||||
onEdit={onEdit}
|
onEdit={onEdit}
|
||||||
onDelete={onDelete}
|
onDelete={onDelete}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)
|
);
|
||||||
}
|
};
|
||||||
|
|||||||
@@ -1,58 +1,68 @@
|
|||||||
"use client"
|
"use client";
|
||||||
|
|
||||||
import type React from "react"
|
import type React from "react";
|
||||||
|
|
||||||
import { useRef } from "react"
|
import { useRef } from "react";
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button";
|
||||||
import { Calendar } from "lucide-react"
|
import { Calendar } from "lucide-react";
|
||||||
|
|
||||||
interface IcsFilePickerProps {
|
interface IcsFilePickerProps {
|
||||||
onFileSelect?: (file: File) => void
|
onFileSelect?: (file: File) => void;
|
||||||
className?: string
|
className?: string;
|
||||||
children?: React.ReactNode
|
children?: React.ReactNode;
|
||||||
variant?: "default" | "destructive" | "outline" | "secondary" | "ghost" | "link"
|
variant?:
|
||||||
size?: "default" | "sm" | "lg" | "icon"
|
| "default"
|
||||||
|
| "destructive"
|
||||||
|
| "outline"
|
||||||
|
| "secondary"
|
||||||
|
| "ghost"
|
||||||
|
| "link";
|
||||||
|
size?: "default" | "sm" | "lg" | "icon";
|
||||||
}
|
}
|
||||||
|
|
||||||
export function IcsFilePicker({
|
export function IcsFilePicker({
|
||||||
onFileSelect,
|
onFileSelect,
|
||||||
className,
|
className,
|
||||||
children,
|
children,
|
||||||
variant = "default",
|
variant = "default",
|
||||||
size = "default",
|
size = "default",
|
||||||
}: IcsFilePickerProps) {
|
}: IcsFilePickerProps) {
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
const handleButtonClick = () => {
|
const handleButtonClick = () => {
|
||||||
fileInputRef.current?.click()
|
fileInputRef.current?.click();
|
||||||
}
|
};
|
||||||
|
|
||||||
const handleFileChange = (event: React.ChangeEvent<HTMLInputElement>) => {
|
const handleFileChange = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
const file = event.target.files?.[0]
|
const file = event.target.files?.[0];
|
||||||
if (file && onFileSelect) {
|
if (file && onFileSelect) {
|
||||||
onFileSelect(file)
|
onFileSelect(file);
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<input
|
<input
|
||||||
ref={fileInputRef}
|
ref={fileInputRef}
|
||||||
type="file"
|
type="file"
|
||||||
accept=".ics"
|
accept=".ics"
|
||||||
onChange={handleFileChange}
|
onChange={handleFileChange}
|
||||||
className="hidden"
|
className="hidden"
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
/>
|
/>
|
||||||
<Button onClick={handleButtonClick} variant={variant} size={size} className={className}>
|
<Button
|
||||||
{children || (
|
onClick={handleButtonClick}
|
||||||
<>
|
variant={variant}
|
||||||
<Calendar className="mr-2 h-4 w-4" />
|
size={size}
|
||||||
Import Calendar
|
className={className}
|
||||||
</>
|
>
|
||||||
)}
|
{children || (
|
||||||
</Button>
|
<>
|
||||||
</>
|
<Calendar className="mr-2 h-4 w-4" />
|
||||||
)
|
Import Calendar
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,77 +1,75 @@
|
|||||||
"use client"
|
"use client";
|
||||||
|
|
||||||
import * as React from "react"
|
import * as React from "react";
|
||||||
import { Moon, Sun, Monitor } from "lucide-react"
|
import { Moon, Sun, Monitor } from "lucide-react";
|
||||||
import { useTheme } from "next-themes"
|
import { useTheme } from "next-themes";
|
||||||
|
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button";
|
||||||
import {
|
import {
|
||||||
DropdownMenu,
|
DropdownMenu,
|
||||||
DropdownMenuContent,
|
DropdownMenuContent,
|
||||||
DropdownMenuItem,
|
DropdownMenuItem,
|
||||||
DropdownMenuTrigger,
|
DropdownMenuTrigger,
|
||||||
} from "@/components/ui/dropdown-menu"
|
} from "@/components/ui/dropdown-menu";
|
||||||
|
|
||||||
type ThemeIconProps = {
|
type ThemeIconProps = {
|
||||||
theme?: string
|
theme?: string;
|
||||||
}
|
};
|
||||||
|
|
||||||
const ThemeIcon = ({ theme }: ThemeIconProps) => {
|
const ThemeIcon = ({ theme }: ThemeIconProps) => {
|
||||||
|
const [mounted, setMounted] = React.useState(false);
|
||||||
|
|
||||||
const [mounted, setMounted] = React.useState(false)
|
React.useEffect(() => {
|
||||||
|
setMounted(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
React.useEffect(() => {
|
if (!mounted) {
|
||||||
setMounted(true)
|
return null;
|
||||||
}, [])
|
}
|
||||||
|
|
||||||
if (!mounted) {
|
switch (theme) {
|
||||||
return null
|
case "light":
|
||||||
}
|
return (
|
||||||
|
<Sun className="absolute h-[1.2rem] w-[1.2rem] scale-100 rotate-0 transition-all dark:scale-0 dark:-rotate-90" />
|
||||||
switch (theme) {
|
);
|
||||||
case "light":
|
case "dark":
|
||||||
return (
|
return (
|
||||||
<Sun className="absolute h-[1.2rem] w-[1.2rem] scale-100 rotate-0 transition-all dark:scale-0 dark:-rotate-90" />
|
<Moon className="absolute h-[1.2rem] w-[1.2rem] scale-0 rotate-90 transition-all dark:scale-100 dark:rotate-0" />
|
||||||
)
|
);
|
||||||
case "dark":
|
case "system":
|
||||||
return (
|
return <Monitor className="absolute h-[1.2rem] w-[1.2rem] scale-100" />;
|
||||||
<Moon className="absolute h-[1.2rem] w-[1.2rem] scale-0 rotate-90 transition-all dark:scale-100 dark:rotate-0" />
|
default:
|
||||||
)
|
return (
|
||||||
case "system":
|
<>
|
||||||
return (
|
<Sun className="h-[1.2rem] w-[1.2rem] scale-100 rotate-0 transition-all dark:scale-0 dark:-rotate-90" />
|
||||||
<Monitor className="absolute h-[1.2rem] w-[1.2rem] scale-100" />
|
<Moon className="absolute h-[1.2rem] w-[1.2rem] scale-0 rotate-90 transition-all dark:scale-100 dark:rotate-0" />
|
||||||
)
|
</>
|
||||||
default:
|
);
|
||||||
return (<>
|
}
|
||||||
<Sun className="h-[1.2rem] w-[1.2rem] scale-100 rotate-0 transition-all dark:scale-0 dark:-rotate-90" />
|
};
|
||||||
<Moon className="absolute h-[1.2rem] w-[1.2rem] scale-0 rotate-90 transition-all dark:scale-100 dark:rotate-0" />
|
|
||||||
</>)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function ModeToggle() {
|
export function ModeToggle() {
|
||||||
const { setTheme, theme } = useTheme()
|
const { setTheme, theme } = useTheme();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DropdownMenu>
|
<DropdownMenu>
|
||||||
<DropdownMenuTrigger asChild>
|
<DropdownMenuTrigger asChild>
|
||||||
<Button variant="outline" size="icon">
|
<Button variant="outline" size="icon">
|
||||||
<ThemeIcon theme={theme} />
|
<ThemeIcon theme={theme} />
|
||||||
<span className="sr-only">Toggle theme</span>
|
<span className="sr-only">Toggle theme</span>
|
||||||
</Button>
|
</Button>
|
||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
<DropdownMenuContent align="end">
|
<DropdownMenuContent align="end">
|
||||||
<DropdownMenuItem onClick={() => setTheme("light")}>
|
<DropdownMenuItem onClick={() => setTheme("light")}>
|
||||||
Light
|
Light
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
<DropdownMenuItem onClick={() => setTheme("dark")}>
|
<DropdownMenuItem onClick={() => setTheme("dark")}>
|
||||||
Dark
|
Dark
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
<DropdownMenuItem onClick={() => setTheme("system")}>
|
<DropdownMenuItem onClick={() => setTheme("system")}>
|
||||||
System
|
System
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
</DropdownMenuContent>
|
</DropdownMenuContent>
|
||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,153 +1,184 @@
|
|||||||
"use client"
|
"use client";
|
||||||
|
|
||||||
import { useState } from "react"
|
import { useState } from "react";
|
||||||
import { Input } from "@/components/ui/input"
|
import { Input } from "@/components/ui/input";
|
||||||
import { Label } from "@/components/ui/label"
|
import { Label } from "@/components/ui/label";
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
import {
|
||||||
import { Checkbox } from "@/components/ui/checkbox"
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/components/ui/select";
|
||||||
|
import { Checkbox } from "@/components/ui/checkbox";
|
||||||
|
|
||||||
type Recurrence = {
|
type Recurrence = {
|
||||||
freq: "NONE" | "DAILY" | "WEEKLY" | "MONTHLY"
|
freq: "NONE" | "DAILY" | "WEEKLY" | "MONTHLY";
|
||||||
interval: number
|
interval: number;
|
||||||
byDay?: string[]
|
byDay?: string[];
|
||||||
count?: number
|
count?: number;
|
||||||
until?: string
|
until?: string;
|
||||||
}
|
};
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
value?: string
|
value?: string;
|
||||||
onChange: (rrule: string | undefined) => void
|
onChange: (rrule: string | undefined) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function RecurrencePicker({ value, onChange }: Props) {
|
export function RecurrencePicker({ value, onChange }: Props) {
|
||||||
const [rec, setRec] = useState<Recurrence>(() => {
|
const [rec, setRec] = useState<Recurrence>(() => {
|
||||||
// If existing rrule, parse minimally (for simplicity we only rehydrate FREQ and INTERVAL)
|
// If existing rrule, parse minimally (for simplicity we only rehydrate FREQ and INTERVAL)
|
||||||
if (value) {
|
if (value) {
|
||||||
const parts = Object.fromEntries(value.split(";").map((p) => p.split("=")))
|
const parts = Object.fromEntries(
|
||||||
return {
|
value.split(";").map((p) => p.split("=")),
|
||||||
freq: parts.FREQ || "NONE",
|
);
|
||||||
interval: parts.INTERVAL ? Number.parseInt(parts.INTERVAL, 10) : 1,
|
return {
|
||||||
byDay: parts.BYDAY ? parts.BYDAY.split(",") : [],
|
freq: parts.FREQ || "NONE",
|
||||||
count: parts.COUNT ? Number.parseInt(parts.COUNT, 10) : undefined,
|
interval: parts.INTERVAL ? Number.parseInt(parts.INTERVAL, 10) : 1,
|
||||||
until: parts.UNTIL,
|
byDay: parts.BYDAY ? parts.BYDAY.split(",") : [],
|
||||||
}
|
count: parts.COUNT ? Number.parseInt(parts.COUNT, 10) : undefined,
|
||||||
}
|
until: parts.UNTIL,
|
||||||
return { freq: "NONE", interval: 1 }
|
};
|
||||||
})
|
}
|
||||||
|
return { freq: "NONE", interval: 1 };
|
||||||
|
});
|
||||||
|
|
||||||
const update = (updates: Partial<Recurrence>) => {
|
const update = (updates: Partial<Recurrence>) => {
|
||||||
const newRec = { ...rec, ...updates }
|
const newRec = { ...rec, ...updates };
|
||||||
setRec(newRec)
|
setRec(newRec);
|
||||||
|
|
||||||
if (newRec.freq === "NONE") {
|
if (newRec.freq === "NONE") {
|
||||||
onChange(undefined)
|
onChange(undefined);
|
||||||
return
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Build RRULE string
|
// Build RRULE string
|
||||||
let rrule = `FREQ=${newRec.freq};INTERVAL=${newRec.interval}`
|
let rrule = `FREQ=${newRec.freq};INTERVAL=${newRec.interval}`;
|
||||||
if (newRec.freq === "WEEKLY" && newRec.byDay?.length) {
|
if (newRec.freq === "WEEKLY" && newRec.byDay?.length) {
|
||||||
rrule += `;BYDAY=${newRec.byDay.join(",")}`
|
rrule += `;BYDAY=${newRec.byDay.join(",")}`;
|
||||||
}
|
}
|
||||||
if (newRec.count) rrule += `;COUNT=${newRec.count}`
|
if (newRec.count) rrule += `;COUNT=${newRec.count}`;
|
||||||
if (newRec.until) rrule += `;UNTIL=${newRec.until.replace(/-/g, "")}T000000Z`
|
if (newRec.until)
|
||||||
|
rrule += `;UNTIL=${newRec.until.replace(/-/g, "")}T000000Z`;
|
||||||
|
|
||||||
onChange(rrule)
|
onChange(rrule);
|
||||||
}
|
};
|
||||||
|
|
||||||
const toggleDay = (day: string) => {
|
const toggleDay = (day: string) => {
|
||||||
const byDay = rec.byDay || []
|
const byDay = rec.byDay || [];
|
||||||
const newByDay = byDay.includes(day) ? byDay.filter((d) => d !== day) : [...byDay, day]
|
const newByDay = byDay.includes(day)
|
||||||
update({ byDay: newByDay })
|
? byDay.filter((d) => d !== day)
|
||||||
}
|
: [...byDay, day];
|
||||||
|
update({ byDay: newByDay });
|
||||||
|
};
|
||||||
|
|
||||||
const dayLabels = {
|
const dayLabels = {
|
||||||
MO: "Mon",
|
MO: "Mon",
|
||||||
TU: "Tue",
|
TU: "Tue",
|
||||||
WE: "Wed",
|
WE: "Wed",
|
||||||
TH: "Thu",
|
TH: "Thu",
|
||||||
FR: "Fri",
|
FR: "Fri",
|
||||||
SA: "Sat",
|
SA: "Sat",
|
||||||
SU: "Sun",
|
SU: "Sun",
|
||||||
}
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="">
|
<div className="">
|
||||||
<Label htmlFor="frequency" className="pt-4 pb-2 pl-1">Repeats</Label>
|
<Label htmlFor="frequency" className="pt-4 pb-2 pl-1">
|
||||||
<div className="space-y-2">
|
Repeats
|
||||||
<Select value={rec.freq} onValueChange={(value) => update({ freq: value as Recurrence["freq"] })}>
|
</Label>
|
||||||
<SelectTrigger id="frequency">
|
<div className="space-y-2">
|
||||||
<SelectValue />
|
<Select
|
||||||
</SelectTrigger>
|
value={rec.freq}
|
||||||
<SelectContent>
|
onValueChange={(value) =>
|
||||||
<SelectItem value="NONE">Does not repeat</SelectItem>
|
update({ freq: value as Recurrence["freq"] })
|
||||||
<SelectItem value="DAILY">Daily</SelectItem>
|
}
|
||||||
<SelectItem value="WEEKLY">Weekly</SelectItem>
|
>
|
||||||
<SelectItem value="MONTHLY">Monthly</SelectItem>
|
<SelectTrigger id="frequency">
|
||||||
</SelectContent>
|
<SelectValue />
|
||||||
</Select>
|
</SelectTrigger>
|
||||||
</div>
|
<SelectContent>
|
||||||
|
<SelectItem value="NONE">Does not repeat</SelectItem>
|
||||||
|
<SelectItem value="DAILY">Daily</SelectItem>
|
||||||
|
<SelectItem value="WEEKLY">Weekly</SelectItem>
|
||||||
|
<SelectItem value="MONTHLY">Monthly</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
{rec.freq !== "NONE" && (
|
{rec.freq !== "NONE" && (
|
||||||
<>
|
<>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="interval">
|
<Label htmlFor="interval">
|
||||||
Interval (every {rec.interval} {rec.freq === "DAILY" ? "day" : rec.freq === "WEEKLY" ? "week" : "month"}
|
Interval (every {rec.interval}{" "}
|
||||||
{rec.interval > 1 ? "s" : ""})
|
{rec.freq === "DAILY"
|
||||||
</Label>
|
? "day"
|
||||||
<Input
|
: rec.freq === "WEEKLY"
|
||||||
id="interval"
|
? "week"
|
||||||
type="number"
|
: "month"}
|
||||||
min={1}
|
{rec.interval > 1 ? "s" : ""})
|
||||||
value={rec.interval}
|
</Label>
|
||||||
onChange={(e) => update({ interval: Number.parseInt(e.target.value, 10) || 1 })}
|
<Input
|
||||||
className="w-24"
|
id="interval"
|
||||||
/>
|
type="number"
|
||||||
</div>
|
min={1}
|
||||||
|
value={rec.interval}
|
||||||
|
onChange={(e) =>
|
||||||
|
update({ interval: Number.parseInt(e.target.value, 10) || 1 })
|
||||||
|
}
|
||||||
|
className="w-24"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
{rec.freq === "WEEKLY" && (
|
{rec.freq === "WEEKLY" && (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label>Days of the week</Label>
|
<Label>Days of the week</Label>
|
||||||
<div className="flex flex-wrap gap-4">
|
<div className="flex flex-wrap gap-4">
|
||||||
{["MO", "TU", "WE", "TH", "FR", "SA", "SU"].map((day) => (
|
{["MO", "TU", "WE", "TH", "FR", "SA", "SU"].map((day) => (
|
||||||
<div key={day} className="flex items-center space-x-2">
|
<div key={day} className="flex items-center space-x-2">
|
||||||
<Checkbox
|
<Checkbox
|
||||||
id={day}
|
id={day}
|
||||||
checked={rec.byDay?.includes(day) || false}
|
checked={rec.byDay?.includes(day) || false}
|
||||||
onCheckedChange={() => toggleDay(day)}
|
onCheckedChange={() => toggleDay(day)}
|
||||||
/>
|
/>
|
||||||
<Label htmlFor={day} className="text-sm font-normal">
|
<Label htmlFor={day} className="text-sm font-normal">
|
||||||
{dayLabels[day as keyof typeof dayLabels]}
|
{dayLabels[day as keyof typeof dayLabels]}
|
||||||
</Label>
|
</Label>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="count">End after (occurrences)</Label>
|
<Label htmlFor="count">End after (occurrences)</Label>
|
||||||
<Input
|
<Input
|
||||||
id="count"
|
id="count"
|
||||||
type="number"
|
type="number"
|
||||||
placeholder="e.g. 10"
|
placeholder="e.g. 10"
|
||||||
value={rec.count || ""}
|
value={rec.count || ""}
|
||||||
onChange={(e) => update({ count: e.target.value ? Number.parseInt(e.target.value, 10) : undefined })}
|
onChange={(e) =>
|
||||||
/>
|
update({
|
||||||
</div>
|
count: e.target.value
|
||||||
<div className="space-y-2">
|
? Number.parseInt(e.target.value, 10)
|
||||||
<Label htmlFor="until">End by date</Label>
|
: undefined,
|
||||||
<Input
|
})
|
||||||
id="until"
|
}
|
||||||
type="date"
|
/>
|
||||||
value={rec.until || ""}
|
</div>
|
||||||
onChange={(e) => update({ until: e.target.value || undefined })}
|
<div className="space-y-2">
|
||||||
/>
|
<Label htmlFor="until">End by date</Label>
|
||||||
</div>
|
<Input
|
||||||
</div>
|
id="until"
|
||||||
</>
|
type="date"
|
||||||
)}
|
value={rec.until || ""}
|
||||||
</div>
|
onChange={(e) => update({ until: e.target.value || undefined })}
|
||||||
)
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,244 +1,306 @@
|
|||||||
import { Badge } from "@/components/ui/badge"
|
import { Badge } from "@/components/ui/badge";
|
||||||
import type { RecurrenceRule } from "@/lib/rfc5545-types"
|
import type { RecurrenceRule } from "@/lib/rfc5545-types";
|
||||||
|
|
||||||
interface RRuleDisplayProps {
|
interface RRuleDisplayProps {
|
||||||
rrule: string | RecurrenceRule
|
rrule: string | RecurrenceRule;
|
||||||
className?: string
|
className?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function RRuleDisplay({ rrule, className }: RRuleDisplayProps) {
|
export function RRuleDisplay({ rrule, className }: RRuleDisplayProps) {
|
||||||
const parsedRule = typeof rrule === 'string' ? parseRRuleString(rrule) : rrule
|
const parsedRule =
|
||||||
const humanText = formatRRuleToHuman(parsedRule)
|
typeof rrule === "string" ? parseRRuleString(rrule) : rrule;
|
||||||
|
const humanText = formatRRuleToHuman(parsedRule);
|
||||||
return (
|
|
||||||
<div className={className}>
|
return (
|
||||||
<span className="text-sm text-muted-foreground">{humanText}</span>
|
<div className={className}>
|
||||||
</div>
|
<span className="text-sm text-muted-foreground">{humanText}</span>
|
||||||
)
|
</div>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
interface RRuleDisplayDetailedProps {
|
interface RRuleDisplayDetailedProps {
|
||||||
rrule: string | RecurrenceRule
|
rrule: string | RecurrenceRule;
|
||||||
className?: string
|
className?: string;
|
||||||
showBadges?: boolean
|
showBadges?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function RRuleDisplayDetailed({ rrule, className, showBadges = true }: RRuleDisplayDetailedProps) {
|
export function RRuleDisplayDetailed({
|
||||||
const parsedRule = typeof rrule === 'string' ? parseRRuleString(rrule) : rrule
|
rrule,
|
||||||
const humanText = formatRRuleToHuman(parsedRule)
|
className,
|
||||||
const details = getRRuleDetails(parsedRule)
|
showBadges = true,
|
||||||
|
}: RRuleDisplayDetailedProps) {
|
||||||
return (
|
const parsedRule =
|
||||||
<div className={className}>
|
typeof rrule === "string" ? parseRRuleString(rrule) : rrule;
|
||||||
<div className="space-y-2">
|
const humanText = formatRRuleToHuman(parsedRule);
|
||||||
<div className="text-sm font-medium">{humanText}</div>
|
const details = getRRuleDetails(parsedRule);
|
||||||
|
|
||||||
{showBadges && details.length > 0 && (
|
return (
|
||||||
<div className="flex flex-wrap gap-1">
|
<div className={className}>
|
||||||
{details.map((detail, index) => (
|
<div className="space-y-2">
|
||||||
<Badge key={index} variant="outline" className="text-xs">
|
<div className="text-sm font-medium">{humanText}</div>
|
||||||
{detail}
|
|
||||||
</Badge>
|
{showBadges && details.length > 0 && (
|
||||||
))}
|
<div className="flex flex-wrap gap-1">
|
||||||
</div>
|
{details.map((detail, index) => (
|
||||||
)}
|
<Badge key={index} variant="outline" className="text-xs">
|
||||||
</div>
|
{detail}
|
||||||
</div>
|
</Badge>
|
||||||
)
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function parseRRuleString(rruleString: string): RecurrenceRule {
|
function parseRRuleString(rruleString: string): RecurrenceRule {
|
||||||
const parts = Object.fromEntries(rruleString.split(";").map(p => p.split("=")))
|
const parts = Object.fromEntries(
|
||||||
|
rruleString.split(";").map((p) => p.split("=")),
|
||||||
return {
|
);
|
||||||
freq: parts.FREQ as RecurrenceRule['freq'],
|
|
||||||
until: parts.UNTIL ? new Date(parts.UNTIL.replace(/(\d{4})(\d{2})(\d{2})T(\d{2})(\d{2})(\d{2})Z?/, '$1-$2-$3T$4:$5:$6Z')).toISOString() : undefined,
|
return {
|
||||||
count: parts.COUNT ? parseInt(parts.COUNT, 10) : undefined,
|
freq: parts.FREQ as RecurrenceRule["freq"],
|
||||||
interval: parts.INTERVAL ? parseInt(parts.INTERVAL, 10) : undefined,
|
until: parts.UNTIL
|
||||||
bySecond: parts.BYSECOND ? parts.BYSECOND.split(",").map((n: string) => parseInt(n, 10)) : undefined,
|
? new Date(
|
||||||
byMinute: parts.BYMINUTE ? parts.BYMINUTE.split(",").map((n: string) => parseInt(n, 10)) : undefined,
|
parts.UNTIL.replace(
|
||||||
byHour: parts.BYHOUR ? parts.BYHOUR.split(",").map((n: string) => parseInt(n, 10)) : undefined,
|
/(\d{4})(\d{2})(\d{2})T(\d{2})(\d{2})(\d{2})Z?/,
|
||||||
byDay: parts.BYDAY ? parts.BYDAY.split(",") : undefined,
|
"$1-$2-$3T$4:$5:$6Z",
|
||||||
byMonthDay: parts.BYMONTHDAY ? parts.BYMONTHDAY.split(",").map((n: string) => parseInt(n, 10)) : undefined,
|
),
|
||||||
byYearDay: parts.BYYEARDAY ? parts.BYYEARDAY.split(",").map((n: string) => parseInt(n, 10)) : undefined,
|
).toISOString()
|
||||||
byWeekNo: parts.BYWEEKNO ? parts.BYWEEKNO.split(",").map((n: string) => parseInt(n, 10)) : undefined,
|
: undefined,
|
||||||
byMonth: parts.BYMONTH ? parts.BYMONTH.split(",").map((n: string) => parseInt(n, 10)) : undefined,
|
count: parts.COUNT ? parseInt(parts.COUNT, 10) : undefined,
|
||||||
bySetPos: parts.BYSETPOS ? parts.BYSETPOS.split(",").map((n: string) => parseInt(n, 10)) : undefined,
|
interval: parts.INTERVAL ? parseInt(parts.INTERVAL, 10) : undefined,
|
||||||
wkst: parts.WKST as RecurrenceRule['wkst'],
|
bySecond: parts.BYSECOND
|
||||||
}
|
? parts.BYSECOND.split(",").map((n: string) => parseInt(n, 10))
|
||||||
|
: undefined,
|
||||||
|
byMinute: parts.BYMINUTE
|
||||||
|
? parts.BYMINUTE.split(",").map((n: string) => parseInt(n, 10))
|
||||||
|
: undefined,
|
||||||
|
byHour: parts.BYHOUR
|
||||||
|
? parts.BYHOUR.split(",").map((n: string) => parseInt(n, 10))
|
||||||
|
: undefined,
|
||||||
|
byDay: parts.BYDAY ? parts.BYDAY.split(",") : undefined,
|
||||||
|
byMonthDay: parts.BYMONTHDAY
|
||||||
|
? parts.BYMONTHDAY.split(",").map((n: string) => parseInt(n, 10))
|
||||||
|
: undefined,
|
||||||
|
byYearDay: parts.BYYEARDAY
|
||||||
|
? parts.BYYEARDAY.split(",").map((n: string) => parseInt(n, 10))
|
||||||
|
: undefined,
|
||||||
|
byWeekNo: parts.BYWEEKNO
|
||||||
|
? parts.BYWEEKNO.split(",").map((n: string) => parseInt(n, 10))
|
||||||
|
: undefined,
|
||||||
|
byMonth: parts.BYMONTH
|
||||||
|
? parts.BYMONTH.split(",").map((n: string) => parseInt(n, 10))
|
||||||
|
: undefined,
|
||||||
|
bySetPos: parts.BYSETPOS
|
||||||
|
? parts.BYSETPOS.split(",").map((n: string) => parseInt(n, 10))
|
||||||
|
: undefined,
|
||||||
|
wkst: parts.WKST as RecurrenceRule["wkst"],
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatRRuleToHuman(rule: RecurrenceRule): string {
|
function formatRRuleToHuman(rule: RecurrenceRule): string {
|
||||||
const { freq, interval = 1, count, until, byDay, byMonthDay, byMonth, byHour, byMinute, bySecond } = rule
|
const {
|
||||||
|
freq,
|
||||||
let text = ""
|
interval = 1,
|
||||||
|
count,
|
||||||
// Base frequency
|
until,
|
||||||
switch (freq) {
|
byDay,
|
||||||
case 'SECONDLY':
|
byMonthDay,
|
||||||
text = interval === 1 ? "Every second" : `Every ${interval} seconds`
|
byMonth,
|
||||||
break
|
byHour,
|
||||||
case 'MINUTELY':
|
byMinute,
|
||||||
text = interval === 1 ? "Every minute" : `Every ${interval} minutes`
|
bySecond,
|
||||||
break
|
} = rule;
|
||||||
case 'HOURLY':
|
|
||||||
text = interval === 1 ? "Every hour" : `Every ${interval} hours`
|
let text = "";
|
||||||
break
|
|
||||||
case 'DAILY':
|
// Base frequency
|
||||||
text = interval === 1 ? "Daily" : `Every ${interval} days`
|
switch (freq) {
|
||||||
break
|
case "SECONDLY":
|
||||||
case 'WEEKLY':
|
text = interval === 1 ? "Every second" : `Every ${interval} seconds`;
|
||||||
text = interval === 1 ? "Weekly" : `Every ${interval} weeks`
|
break;
|
||||||
break
|
case "MINUTELY":
|
||||||
case 'MONTHLY':
|
text = interval === 1 ? "Every minute" : `Every ${interval} minutes`;
|
||||||
text = interval === 1 ? "Monthly" : `Every ${interval} months`
|
break;
|
||||||
break
|
case "HOURLY":
|
||||||
case 'YEARLY':
|
text = interval === 1 ? "Every hour" : `Every ${interval} hours`;
|
||||||
text = interval === 1 ? "Yearly" : `Every ${interval} years`
|
break;
|
||||||
break
|
case "DAILY":
|
||||||
}
|
text = interval === 1 ? "Daily" : `Every ${interval} days`;
|
||||||
|
break;
|
||||||
// Add day specifications
|
case "WEEKLY":
|
||||||
if (byDay?.length) {
|
text = interval === 1 ? "Weekly" : `Every ${interval} weeks`;
|
||||||
const dayNames = {
|
break;
|
||||||
'SU': 'Sunday', 'MO': 'Monday', 'TU': 'Tuesday', 'WE': 'Wednesday',
|
case "MONTHLY":
|
||||||
'TH': 'Thursday', 'FR': 'Friday', 'SA': 'Saturday'
|
text = interval === 1 ? "Monthly" : `Every ${interval} months`;
|
||||||
}
|
break;
|
||||||
|
case "YEARLY":
|
||||||
const days = byDay.map(day => {
|
text = interval === 1 ? "Yearly" : `Every ${interval} years`;
|
||||||
// Handle numbered days like "2TU" (second Tuesday)
|
break;
|
||||||
const match = day.match(/^(-?\d+)?([A-Z]{2})$/)
|
}
|
||||||
if (match) {
|
|
||||||
const [, num, dayCode] = match
|
// Add day specifications
|
||||||
const dayName = dayNames[dayCode as keyof typeof dayNames]
|
if (byDay?.length) {
|
||||||
if (num) {
|
const dayNames = {
|
||||||
const ordinal = getOrdinal(parseInt(num))
|
SU: "Sunday",
|
||||||
return `${ordinal} ${dayName}`
|
MO: "Monday",
|
||||||
}
|
TU: "Tuesday",
|
||||||
return dayName
|
WE: "Wednesday",
|
||||||
}
|
TH: "Thursday",
|
||||||
return day
|
FR: "Friday",
|
||||||
})
|
SA: "Saturday",
|
||||||
|
};
|
||||||
if (freq === 'WEEKLY') {
|
|
||||||
text += ` on ${formatList(days)}`
|
const days = byDay.map((day) => {
|
||||||
} else {
|
// Handle numbered days like "2TU" (second Tuesday)
|
||||||
text += ` on ${formatList(days)}`
|
const match = day.match(/^(-?\d+)?([A-Z]{2})$/);
|
||||||
}
|
if (match) {
|
||||||
}
|
const [, num, dayCode] = match;
|
||||||
|
const dayName = dayNames[dayCode as keyof typeof dayNames];
|
||||||
// Add month day specifications
|
if (num) {
|
||||||
if (byMonthDay?.length) {
|
const ordinal = getOrdinal(parseInt(num));
|
||||||
const days = byMonthDay.map(day => {
|
return `${ordinal} ${dayName}`;
|
||||||
if (day < 0) {
|
}
|
||||||
return `${getOrdinal(Math.abs(day))} to last day`
|
return dayName;
|
||||||
}
|
}
|
||||||
return getOrdinal(day)
|
return day;
|
||||||
})
|
});
|
||||||
text += ` on the ${formatList(days)}`
|
|
||||||
}
|
if (freq === "WEEKLY") {
|
||||||
|
text += ` on ${formatList(days)}`;
|
||||||
// Add month specifications
|
} else {
|
||||||
if (byMonth?.length) {
|
text += ` on ${formatList(days)}`;
|
||||||
const monthNames = [
|
}
|
||||||
'January', 'February', 'March', 'April', 'May', 'June',
|
}
|
||||||
'July', 'August', 'September', 'October', 'November', 'December'
|
|
||||||
]
|
// Add month day specifications
|
||||||
const months = byMonth.map(month => monthNames[month - 1])
|
if (byMonthDay?.length) {
|
||||||
text += ` in ${formatList(months)}`
|
const days = byMonthDay.map((day) => {
|
||||||
}
|
if (day < 0) {
|
||||||
|
return `${getOrdinal(Math.abs(day))} to last day`;
|
||||||
// Add time specifications
|
}
|
||||||
if (byHour?.length || byMinute?.length || bySecond?.length) {
|
return getOrdinal(day);
|
||||||
const timeSpecs = []
|
});
|
||||||
if (byHour?.length) {
|
text += ` on the ${formatList(days)}`;
|
||||||
const hours = byHour.map(h => `${h.toString().padStart(2, '0')}:00`)
|
}
|
||||||
timeSpecs.push(`at ${formatList(hours)}`)
|
|
||||||
}
|
// Add month specifications
|
||||||
if (byMinute?.length && !byHour?.length) {
|
if (byMonth?.length) {
|
||||||
timeSpecs.push(`at minute ${formatList(byMinute.map(String))}`)
|
const monthNames = [
|
||||||
}
|
"January",
|
||||||
if (bySecond?.length && !byHour?.length && !byMinute?.length) {
|
"February",
|
||||||
timeSpecs.push(`at second ${formatList(bySecond.map(String))}`)
|
"March",
|
||||||
}
|
"April",
|
||||||
if (timeSpecs.length) {
|
"May",
|
||||||
text += ` ${timeSpecs.join(' ')}`
|
"June",
|
||||||
}
|
"July",
|
||||||
}
|
"August",
|
||||||
|
"September",
|
||||||
// Add end conditions
|
"October",
|
||||||
if (count) {
|
"November",
|
||||||
text += `, ${count} time${count === 1 ? '' : 's'}`
|
"December",
|
||||||
} else if (until) {
|
];
|
||||||
const date = new Date(until)
|
const months = byMonth.map((month) => monthNames[month - 1]);
|
||||||
text += `, until ${date.toLocaleDateString()}`
|
text += ` in ${formatList(months)}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
return text
|
// Add time specifications
|
||||||
|
if (byHour?.length || byMinute?.length || bySecond?.length) {
|
||||||
|
const timeSpecs = [];
|
||||||
|
if (byHour?.length) {
|
||||||
|
const hours = byHour.map((h) => `${h.toString().padStart(2, "0")}:00`);
|
||||||
|
timeSpecs.push(`at ${formatList(hours)}`);
|
||||||
|
}
|
||||||
|
if (byMinute?.length && !byHour?.length) {
|
||||||
|
timeSpecs.push(`at minute ${formatList(byMinute.map(String))}`);
|
||||||
|
}
|
||||||
|
if (bySecond?.length && !byHour?.length && !byMinute?.length) {
|
||||||
|
timeSpecs.push(`at second ${formatList(bySecond.map(String))}`);
|
||||||
|
}
|
||||||
|
if (timeSpecs.length) {
|
||||||
|
text += ` ${timeSpecs.join(" ")}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add end conditions
|
||||||
|
if (count) {
|
||||||
|
text += `, ${count} time${count === 1 ? "" : "s"}`;
|
||||||
|
} else if (until) {
|
||||||
|
const date = new Date(until);
|
||||||
|
text += `, until ${date.toLocaleDateString()}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return text;
|
||||||
}
|
}
|
||||||
|
|
||||||
function getRRuleDetails(rule: RecurrenceRule): string[] {
|
function getRRuleDetails(rule: RecurrenceRule): string[] {
|
||||||
const details: string[] = []
|
const details: string[] = [];
|
||||||
|
|
||||||
if (rule.wkst && rule.wkst !== 'MO') {
|
if (rule.wkst && rule.wkst !== "MO") {
|
||||||
const dayNames = {
|
const dayNames = {
|
||||||
'SU': 'Sunday', 'MO': 'Monday', 'TU': 'Tuesday', 'WE': 'Wednesday',
|
SU: "Sunday",
|
||||||
'TH': 'Thursday', 'FR': 'Friday', 'SA': 'Saturday'
|
MO: "Monday",
|
||||||
}
|
TU: "Tuesday",
|
||||||
details.push(`Week starts ${dayNames[rule.wkst]}`)
|
WE: "Wednesday",
|
||||||
}
|
TH: "Thursday",
|
||||||
|
FR: "Friday",
|
||||||
if (rule.byWeekNo?.length) {
|
SA: "Saturday",
|
||||||
details.push(`Week ${formatList(rule.byWeekNo.map(String))}`)
|
};
|
||||||
}
|
details.push(`Week starts ${dayNames[rule.wkst]}`);
|
||||||
|
}
|
||||||
if (rule.byYearDay?.length) {
|
|
||||||
details.push(`Day ${formatList(rule.byYearDay.map(String))} of year`)
|
if (rule.byWeekNo?.length) {
|
||||||
}
|
details.push(`Week ${formatList(rule.byWeekNo.map(String))}`);
|
||||||
|
}
|
||||||
if (rule.bySetPos?.length) {
|
|
||||||
const positions = rule.bySetPos.map(pos => {
|
if (rule.byYearDay?.length) {
|
||||||
if (pos < 0) {
|
details.push(`Day ${formatList(rule.byYearDay.map(String))} of year`);
|
||||||
return `${getOrdinal(Math.abs(pos))} to last`
|
}
|
||||||
}
|
|
||||||
return getOrdinal(pos)
|
if (rule.bySetPos?.length) {
|
||||||
})
|
const positions = rule.bySetPos.map((pos) => {
|
||||||
details.push(`Position ${formatList(positions)}`)
|
if (pos < 0) {
|
||||||
}
|
return `${getOrdinal(Math.abs(pos))} to last`;
|
||||||
|
}
|
||||||
return details
|
return getOrdinal(pos);
|
||||||
|
});
|
||||||
|
details.push(`Position ${formatList(positions)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return details;
|
||||||
}
|
}
|
||||||
|
|
||||||
function getOrdinal(num: number): string {
|
function getOrdinal(num: number): string {
|
||||||
const suffix = ['th', 'st', 'nd', 'rd']
|
const suffix = ["th", "st", "nd", "rd"];
|
||||||
const v = num % 100
|
const v = num % 100;
|
||||||
return num + (suffix[(v - 20) % 10] || suffix[v] || suffix[0])
|
return num + (suffix[(v - 20) % 10] || suffix[v] || suffix[0]);
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatList(items: string[]): string {
|
function formatList(items: string[]): string {
|
||||||
if (items.length === 0) return ''
|
if (items.length === 0) return "";
|
||||||
if (items.length === 1) return items[0]
|
if (items.length === 1) return items[0];
|
||||||
if (items.length === 2) return `${items[0]} and ${items[1]}`
|
if (items.length === 2) return `${items[0]} and ${items[1]}`;
|
||||||
return `${items.slice(0, -1).join(', ')}, and ${items[items.length - 1]}`
|
return `${items.slice(0, -1).join(", ")}, and ${items[items.length - 1]}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Hook for easy usage in components
|
// Hook for easy usage in components
|
||||||
export function useRRuleDisplay(rrule?: string) {
|
export function useRRuleDisplay(rrule?: string) {
|
||||||
if (!rrule) return null
|
if (!rrule) return null;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const parsedRule = parseRRuleString(rrule)
|
const parsedRule = parseRRuleString(rrule);
|
||||||
return {
|
return {
|
||||||
humanText: formatRRuleToHuman(parsedRule),
|
humanText: formatRRuleToHuman(parsedRule),
|
||||||
details: getRRuleDetails(parsedRule),
|
details: getRRuleDetails(parsedRule),
|
||||||
parsedRule
|
parsedRule,
|
||||||
}
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return {
|
return {
|
||||||
humanText: "Invalid recurrence rule",
|
humanText: "Invalid recurrence rule",
|
||||||
details: [],
|
details: [],
|
||||||
parsedRule: null,
|
parsedRule: null,
|
||||||
error: error instanceof Error ? error.message : String(error)
|
error: error instanceof Error ? error.message : String(error),
|
||||||
}
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,44 +1,44 @@
|
|||||||
"use client"
|
"use client";
|
||||||
|
|
||||||
import { signOut, useSession } from "@/lib/auth-client"
|
import { signOut, useSession } from "@/lib/auth-client";
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button";
|
||||||
import { useRouter } from "next/navigation"
|
import { useRouter } from "next/navigation";
|
||||||
import { toast } from "sonner"
|
import { toast } from "sonner";
|
||||||
|
|
||||||
export default function SignIn() {
|
export default function SignIn() {
|
||||||
const { data: session, isPending } = useSession()
|
const { data: session, isPending } = useSession();
|
||||||
const router = useRouter()
|
const router = useRouter();
|
||||||
|
|
||||||
const handleSignOut = async () => {
|
const handleSignOut = async () => {
|
||||||
try {
|
try {
|
||||||
await signOut()
|
await signOut();
|
||||||
router.push("/")
|
router.push("/");
|
||||||
} catch (_error) {
|
} catch (_error) {
|
||||||
toast.error("Failed to sign out. Please try again.")
|
toast.error("Failed to sign out. Please try again.");
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
|
|
||||||
if (isPending) {
|
if (isPending) {
|
||||||
return <div className="h-8 w-16 bg-muted animate-pulse rounded"></div>
|
return <div className="h-8 w-16 bg-muted animate-pulse rounded"></div>;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (session?.user) {
|
if (session?.user) {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-4">
|
||||||
<Button onClick={handleSignOut} variant="ghost" size="default">
|
<Button onClick={handleSignOut} variant="ghost" size="default">
|
||||||
Sign Out
|
Sign Out
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Button
|
<Button
|
||||||
onClick={() => router.push("/auth/signin")}
|
onClick={() => router.push("/auth/signin")}
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="default"
|
size="default"
|
||||||
>
|
>
|
||||||
Sign In
|
Sign In
|
||||||
</Button>
|
</Button>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
"use client"
|
"use client";
|
||||||
|
|
||||||
import * as React from "react"
|
import * as React from "react";
|
||||||
import { ThemeProvider as NextThemesProvider } from "next-themes"
|
import { ThemeProvider as NextThemesProvider } from "next-themes";
|
||||||
|
|
||||||
export function ThemeProvider({
|
export function ThemeProvider({
|
||||||
children,
|
children,
|
||||||
...props
|
...props
|
||||||
}: React.ComponentProps<typeof NextThemesProvider>) {
|
}: React.ComponentProps<typeof NextThemesProvider>) {
|
||||||
return <NextThemesProvider {...props}>{children}</NextThemesProvider>
|
return <NextThemesProvider {...props}>{children}</NextThemesProvider>;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,46 +1,46 @@
|
|||||||
import * as React from "react"
|
import * as React from "react";
|
||||||
import { Slot } from "@radix-ui/react-slot"
|
import { Slot } from "@radix-ui/react-slot";
|
||||||
import { cva, type VariantProps } from "class-variance-authority"
|
import { cva, type VariantProps } from "class-variance-authority";
|
||||||
|
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
const badgeVariants = cva(
|
const badgeVariants = cva(
|
||||||
"inline-flex items-center justify-center rounded-md border px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-[color,box-shadow] overflow-hidden",
|
"inline-flex items-center justify-center rounded-md border px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-[color,box-shadow] overflow-hidden",
|
||||||
{
|
{
|
||||||
variants: {
|
variants: {
|
||||||
variant: {
|
variant: {
|
||||||
default:
|
default:
|
||||||
"border-transparent bg-primary text-primary-foreground [a&]:hover:bg-primary/90",
|
"border-transparent bg-primary text-primary-foreground [a&]:hover:bg-primary/90",
|
||||||
secondary:
|
secondary:
|
||||||
"border-transparent bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90",
|
"border-transparent bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90",
|
||||||
destructive:
|
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",
|
"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:
|
outline:
|
||||||
"text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
|
"text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
defaultVariants: {
|
defaultVariants: {
|
||||||
variant: "default",
|
variant: "default",
|
||||||
},
|
},
|
||||||
}
|
},
|
||||||
)
|
);
|
||||||
|
|
||||||
function Badge({
|
function Badge({
|
||||||
className,
|
className,
|
||||||
variant,
|
variant,
|
||||||
asChild = false,
|
asChild = false,
|
||||||
...props
|
...props
|
||||||
}: React.ComponentProps<"span"> &
|
}: React.ComponentProps<"span"> &
|
||||||
VariantProps<typeof badgeVariants> & { asChild?: boolean }) {
|
VariantProps<typeof badgeVariants> & { asChild?: boolean }) {
|
||||||
const Comp = asChild ? Slot : "span"
|
const Comp = asChild ? Slot : "span";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Comp
|
<Comp
|
||||||
data-slot="badge"
|
data-slot="badge"
|
||||||
className={cn(badgeVariants({ variant }), className)}
|
className={cn(badgeVariants({ variant }), className)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export { Badge, badgeVariants }
|
export { Badge, badgeVariants };
|
||||||
|
|||||||
@@ -1,59 +1,59 @@
|
|||||||
import * as React from "react"
|
import * as React from "react";
|
||||||
import { Slot } from "@radix-ui/react-slot"
|
import { Slot } from "@radix-ui/react-slot";
|
||||||
import { cva, type VariantProps } from "class-variance-authority"
|
import { cva, type VariantProps } from "class-variance-authority";
|
||||||
|
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
const buttonVariants = cva(
|
const buttonVariants = cva(
|
||||||
"active:scale-[.95] inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium duration-100 transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
|
"active:scale-[.95] inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium duration-100 transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
|
||||||
{
|
{
|
||||||
variants: {
|
variants: {
|
||||||
variant: {
|
variant: {
|
||||||
default:
|
default:
|
||||||
"bg-primary text-primary-foreground shadow-xs hover:bg-primary/90",
|
"bg-primary text-primary-foreground shadow-xs hover:bg-primary/90",
|
||||||
destructive:
|
destructive:
|
||||||
"bg-destructive text-white shadow-xs hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
|
"bg-destructive text-white shadow-xs hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
|
||||||
outline:
|
outline:
|
||||||
"border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50",
|
"border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50",
|
||||||
secondary:
|
secondary:
|
||||||
"bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80",
|
"bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80",
|
||||||
ghost:
|
ghost:
|
||||||
"hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
|
"hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
|
||||||
link: "text-primary underline-offset-4 hover:underline",
|
link: "text-primary underline-offset-4 hover:underline",
|
||||||
},
|
},
|
||||||
size: {
|
size: {
|
||||||
default: "h-9 px-4 py-2 has-[>svg]:px-3",
|
default: "h-9 px-4 py-2 has-[>svg]:px-3",
|
||||||
sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5",
|
sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5",
|
||||||
lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
|
lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
|
||||||
icon: "size-9",
|
icon: "size-9",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
defaultVariants: {
|
defaultVariants: {
|
||||||
variant: "default",
|
variant: "default",
|
||||||
size: "default",
|
size: "default",
|
||||||
},
|
},
|
||||||
}
|
},
|
||||||
)
|
);
|
||||||
|
|
||||||
function Button({
|
function Button({
|
||||||
className,
|
className,
|
||||||
variant,
|
variant,
|
||||||
size,
|
size,
|
||||||
asChild = false,
|
asChild = false,
|
||||||
...props
|
...props
|
||||||
}: React.ComponentProps<"button"> &
|
}: React.ComponentProps<"button"> &
|
||||||
VariantProps<typeof buttonVariants> & {
|
VariantProps<typeof buttonVariants> & {
|
||||||
asChild?: boolean
|
asChild?: boolean;
|
||||||
}) {
|
}) {
|
||||||
const Comp = asChild ? Slot : "button"
|
const Comp = asChild ? Slot : "button";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Comp
|
<Comp
|
||||||
data-slot="button"
|
data-slot="button"
|
||||||
className={cn(buttonVariants({ variant, size, className }))}
|
className={cn(buttonVariants({ variant, size, className }))}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export { Button, buttonVariants }
|
export { Button, buttonVariants };
|
||||||
|
|||||||
@@ -1,213 +1,213 @@
|
|||||||
"use client"
|
"use client";
|
||||||
|
|
||||||
import * as React from "react"
|
import * as React from "react";
|
||||||
import {
|
import {
|
||||||
ChevronDownIcon,
|
ChevronDownIcon,
|
||||||
ChevronLeftIcon,
|
ChevronLeftIcon,
|
||||||
ChevronRightIcon,
|
ChevronRightIcon,
|
||||||
} from "lucide-react"
|
} from "lucide-react";
|
||||||
import { DayButton, DayPicker, getDefaultClassNames } from "react-day-picker"
|
import { DayButton, DayPicker, getDefaultClassNames } from "react-day-picker";
|
||||||
|
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils";
|
||||||
import { Button, buttonVariants } from "@/components/ui/button"
|
import { Button, buttonVariants } from "@/components/ui/button";
|
||||||
|
|
||||||
function Calendar({
|
function Calendar({
|
||||||
className,
|
className,
|
||||||
classNames,
|
classNames,
|
||||||
showOutsideDays = true,
|
showOutsideDays = true,
|
||||||
captionLayout = "label",
|
captionLayout = "label",
|
||||||
buttonVariant = "ghost",
|
buttonVariant = "ghost",
|
||||||
formatters,
|
formatters,
|
||||||
components,
|
components,
|
||||||
...props
|
...props
|
||||||
}: React.ComponentProps<typeof DayPicker> & {
|
}: React.ComponentProps<typeof DayPicker> & {
|
||||||
buttonVariant?: React.ComponentProps<typeof Button>["variant"]
|
buttonVariant?: React.ComponentProps<typeof Button>["variant"];
|
||||||
}) {
|
}) {
|
||||||
const defaultClassNames = getDefaultClassNames()
|
const defaultClassNames = getDefaultClassNames();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DayPicker
|
<DayPicker
|
||||||
showOutsideDays={showOutsideDays}
|
showOutsideDays={showOutsideDays}
|
||||||
className={cn(
|
className={cn(
|
||||||
"bg-background group/calendar p-3 [--cell-size:--spacing(8)] [[data-slot=card-content]_&]:bg-transparent [[data-slot=popover-content]_&]:bg-transparent",
|
"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\_next>svg]:rotate-180`,
|
||||||
String.raw`rtl:**:[.rdp-button\_previous>svg]:rotate-180`,
|
String.raw`rtl:**:[.rdp-button\_previous>svg]:rotate-180`,
|
||||||
className
|
className,
|
||||||
)}
|
)}
|
||||||
captionLayout={captionLayout}
|
captionLayout={captionLayout}
|
||||||
formatters={{
|
formatters={{
|
||||||
formatMonthDropdown: (date) =>
|
formatMonthDropdown: (date) =>
|
||||||
date.toLocaleString("default", { month: "short" }),
|
date.toLocaleString("default", { month: "short" }),
|
||||||
...formatters,
|
...formatters,
|
||||||
}}
|
}}
|
||||||
classNames={{
|
classNames={{
|
||||||
root: cn("w-fit", defaultClassNames.root),
|
root: cn("w-fit", defaultClassNames.root),
|
||||||
months: cn(
|
months: cn(
|
||||||
"flex gap-4 flex-col md:flex-row relative",
|
"flex gap-4 flex-col md:flex-row relative",
|
||||||
defaultClassNames.months
|
defaultClassNames.months,
|
||||||
),
|
),
|
||||||
month: cn("flex flex-col w-full gap-4", defaultClassNames.month),
|
month: cn("flex flex-col w-full gap-4", defaultClassNames.month),
|
||||||
nav: cn(
|
nav: cn(
|
||||||
"flex items-center gap-1 w-full absolute top-0 inset-x-0 justify-between",
|
"flex items-center gap-1 w-full absolute top-0 inset-x-0 justify-between",
|
||||||
defaultClassNames.nav
|
defaultClassNames.nav,
|
||||||
),
|
),
|
||||||
button_previous: cn(
|
button_previous: cn(
|
||||||
buttonVariants({ variant: buttonVariant }),
|
buttonVariants({ variant: buttonVariant }),
|
||||||
"size-(--cell-size) aria-disabled:opacity-50 p-0 select-none",
|
"size-(--cell-size) aria-disabled:opacity-50 p-0 select-none",
|
||||||
defaultClassNames.button_previous
|
defaultClassNames.button_previous,
|
||||||
),
|
),
|
||||||
button_next: cn(
|
button_next: cn(
|
||||||
buttonVariants({ variant: buttonVariant }),
|
buttonVariants({ variant: buttonVariant }),
|
||||||
"size-(--cell-size) aria-disabled:opacity-50 p-0 select-none",
|
"size-(--cell-size) aria-disabled:opacity-50 p-0 select-none",
|
||||||
defaultClassNames.button_next
|
defaultClassNames.button_next,
|
||||||
),
|
),
|
||||||
month_caption: cn(
|
month_caption: cn(
|
||||||
"flex items-center justify-center h-(--cell-size) w-full px-(--cell-size)",
|
"flex items-center justify-center h-(--cell-size) w-full px-(--cell-size)",
|
||||||
defaultClassNames.month_caption
|
defaultClassNames.month_caption,
|
||||||
),
|
),
|
||||||
dropdowns: cn(
|
dropdowns: cn(
|
||||||
"w-full flex items-center text-sm font-medium justify-center h-(--cell-size) gap-1.5",
|
"w-full flex items-center text-sm font-medium justify-center h-(--cell-size) gap-1.5",
|
||||||
defaultClassNames.dropdowns
|
defaultClassNames.dropdowns,
|
||||||
),
|
),
|
||||||
dropdown_root: cn(
|
dropdown_root: cn(
|
||||||
"relative has-focus:border-ring border border-input shadow-xs has-focus:ring-ring/50 has-focus:ring-[3px] rounded-md",
|
"relative has-focus:border-ring border border-input shadow-xs has-focus:ring-ring/50 has-focus:ring-[3px] rounded-md",
|
||||||
defaultClassNames.dropdown_root
|
defaultClassNames.dropdown_root,
|
||||||
),
|
),
|
||||||
dropdown: cn(
|
dropdown: cn(
|
||||||
"absolute bg-popover inset-0 opacity-0",
|
"absolute bg-popover inset-0 opacity-0",
|
||||||
defaultClassNames.dropdown
|
defaultClassNames.dropdown,
|
||||||
),
|
),
|
||||||
caption_label: cn(
|
caption_label: cn(
|
||||||
"select-none font-medium",
|
"select-none font-medium",
|
||||||
captionLayout === "label"
|
captionLayout === "label"
|
||||||
? "text-sm"
|
? "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",
|
: "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
|
defaultClassNames.caption_label,
|
||||||
),
|
),
|
||||||
table: "w-full border-collapse",
|
table: "w-full border-collapse",
|
||||||
weekdays: cn("flex", defaultClassNames.weekdays),
|
weekdays: cn("flex", defaultClassNames.weekdays),
|
||||||
weekday: cn(
|
weekday: cn(
|
||||||
"text-muted-foreground rounded-md flex-1 font-normal text-[0.8rem] select-none",
|
"text-muted-foreground rounded-md flex-1 font-normal text-[0.8rem] select-none",
|
||||||
defaultClassNames.weekday
|
defaultClassNames.weekday,
|
||||||
),
|
),
|
||||||
week: cn("flex w-full mt-2", defaultClassNames.week),
|
week: cn("flex w-full mt-2", defaultClassNames.week),
|
||||||
week_number_header: cn(
|
week_number_header: cn(
|
||||||
"select-none w-(--cell-size)",
|
"select-none w-(--cell-size)",
|
||||||
defaultClassNames.week_number_header
|
defaultClassNames.week_number_header,
|
||||||
),
|
),
|
||||||
week_number: cn(
|
week_number: cn(
|
||||||
"text-[0.8rem] select-none text-muted-foreground",
|
"text-[0.8rem] select-none text-muted-foreground",
|
||||||
defaultClassNames.week_number
|
defaultClassNames.week_number,
|
||||||
),
|
),
|
||||||
day: cn(
|
day: cn(
|
||||||
"relative w-full h-full p-0 text-center [&:first-child[data-selected=true]_button]:rounded-l-md [&:last-child[data-selected=true]_button]:rounded-r-md group/day aspect-square select-none",
|
"relative w-full h-full p-0 text-center [&:first-child[data-selected=true]_button]:rounded-l-md [&:last-child[data-selected=true]_button]:rounded-r-md group/day aspect-square select-none",
|
||||||
defaultClassNames.day
|
defaultClassNames.day,
|
||||||
),
|
),
|
||||||
range_start: cn(
|
range_start: cn(
|
||||||
"rounded-l-md bg-accent",
|
"rounded-l-md bg-accent",
|
||||||
defaultClassNames.range_start
|
defaultClassNames.range_start,
|
||||||
),
|
),
|
||||||
range_middle: cn("rounded-none", defaultClassNames.range_middle),
|
range_middle: cn("rounded-none", defaultClassNames.range_middle),
|
||||||
range_end: cn("rounded-r-md bg-accent", defaultClassNames.range_end),
|
range_end: cn("rounded-r-md bg-accent", defaultClassNames.range_end),
|
||||||
today: cn(
|
today: cn(
|
||||||
"bg-accent text-accent-foreground rounded-md data-[selected=true]:rounded-none",
|
"bg-accent text-accent-foreground rounded-md data-[selected=true]:rounded-none",
|
||||||
defaultClassNames.today
|
defaultClassNames.today,
|
||||||
),
|
),
|
||||||
outside: cn(
|
outside: cn(
|
||||||
"text-muted-foreground aria-selected:text-muted-foreground",
|
"text-muted-foreground aria-selected:text-muted-foreground",
|
||||||
defaultClassNames.outside
|
defaultClassNames.outside,
|
||||||
),
|
),
|
||||||
disabled: cn(
|
disabled: cn(
|
||||||
"text-muted-foreground opacity-50",
|
"text-muted-foreground opacity-50",
|
||||||
defaultClassNames.disabled
|
defaultClassNames.disabled,
|
||||||
),
|
),
|
||||||
hidden: cn("invisible", defaultClassNames.hidden),
|
hidden: cn("invisible", defaultClassNames.hidden),
|
||||||
...classNames,
|
...classNames,
|
||||||
}}
|
}}
|
||||||
components={{
|
components={{
|
||||||
Root: ({ className, rootRef, ...props }) => {
|
Root: ({ className, rootRef, ...props }) => {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
data-slot="calendar"
|
data-slot="calendar"
|
||||||
ref={rootRef}
|
ref={rootRef}
|
||||||
className={cn(className)}
|
className={cn(className)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
);
|
||||||
},
|
},
|
||||||
Chevron: ({ className, orientation, ...props }) => {
|
Chevron: ({ className, orientation, ...props }) => {
|
||||||
if (orientation === "left") {
|
if (orientation === "left") {
|
||||||
return (
|
return (
|
||||||
<ChevronLeftIcon className={cn("size-4", className)} {...props} />
|
<ChevronLeftIcon className={cn("size-4", className)} {...props} />
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (orientation === "right") {
|
if (orientation === "right") {
|
||||||
return (
|
return (
|
||||||
<ChevronRightIcon
|
<ChevronRightIcon
|
||||||
className={cn("size-4", className)}
|
className={cn("size-4", className)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ChevronDownIcon className={cn("size-4", className)} {...props} />
|
<ChevronDownIcon className={cn("size-4", className)} {...props} />
|
||||||
)
|
);
|
||||||
},
|
},
|
||||||
DayButton: CalendarDayButton,
|
DayButton: CalendarDayButton,
|
||||||
WeekNumber: ({ children, ...props }) => {
|
WeekNumber: ({ children, ...props }) => {
|
||||||
return (
|
return (
|
||||||
<td {...props}>
|
<td {...props}>
|
||||||
<div className="flex size-(--cell-size) items-center justify-center text-center">
|
<div className="flex size-(--cell-size) items-center justify-center text-center">
|
||||||
{children}
|
{children}
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
)
|
);
|
||||||
},
|
},
|
||||||
...components,
|
...components,
|
||||||
}}
|
}}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function CalendarDayButton({
|
function CalendarDayButton({
|
||||||
className,
|
className,
|
||||||
day,
|
day,
|
||||||
modifiers,
|
modifiers,
|
||||||
...props
|
...props
|
||||||
}: React.ComponentProps<typeof DayButton>) {
|
}: React.ComponentProps<typeof DayButton>) {
|
||||||
const defaultClassNames = getDefaultClassNames()
|
const defaultClassNames = getDefaultClassNames();
|
||||||
|
|
||||||
const ref = React.useRef<HTMLButtonElement>(null)
|
const ref = React.useRef<HTMLButtonElement>(null);
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
if (modifiers.focused) ref.current?.focus()
|
if (modifiers.focused) ref.current?.focus();
|
||||||
}, [modifiers.focused])
|
}, [modifiers.focused]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Button
|
<Button
|
||||||
ref={ref}
|
ref={ref}
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
data-day={day.date.toLocaleDateString()}
|
data-day={day.date.toLocaleDateString()}
|
||||||
data-selected-single={
|
data-selected-single={
|
||||||
modifiers.selected &&
|
modifiers.selected &&
|
||||||
!modifiers.range_start &&
|
!modifiers.range_start &&
|
||||||
!modifiers.range_end &&
|
!modifiers.range_end &&
|
||||||
!modifiers.range_middle
|
!modifiers.range_middle
|
||||||
}
|
}
|
||||||
data-range-start={modifiers.range_start}
|
data-range-start={modifiers.range_start}
|
||||||
data-range-end={modifiers.range_end}
|
data-range-end={modifiers.range_end}
|
||||||
data-range-middle={modifiers.range_middle}
|
data-range-middle={modifiers.range_middle}
|
||||||
className={cn(
|
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",
|
"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,
|
defaultClassNames.day,
|
||||||
className
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export { Calendar, CalendarDayButton }
|
export { Calendar, CalendarDayButton };
|
||||||
|
|||||||
@@ -1,92 +1,92 @@
|
|||||||
import * as React from "react"
|
import * as React from "react";
|
||||||
|
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
function Card({ className, ...props }: React.ComponentProps<"div">) {
|
function Card({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
data-slot="card"
|
data-slot="card"
|
||||||
className={cn(
|
className={cn(
|
||||||
"bg-card text-card-foreground flex flex-col gap-6 rounded-xl border py-6 shadow-sm",
|
"bg-card text-card-foreground flex flex-col gap-6 rounded-xl border py-6 shadow-sm",
|
||||||
className
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
|
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
data-slot="card-header"
|
data-slot="card-header"
|
||||||
className={cn(
|
className={cn(
|
||||||
"@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-1.5 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6",
|
"@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-1.5 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6",
|
||||||
className
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
|
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
data-slot="card-title"
|
data-slot="card-title"
|
||||||
className={cn("leading-none font-semibold", className)}
|
className={cn("leading-none font-semibold", className)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
|
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
data-slot="card-description"
|
data-slot="card-description"
|
||||||
className={cn("text-muted-foreground text-sm", className)}
|
className={cn("text-muted-foreground text-sm", className)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function CardAction({ className, ...props }: React.ComponentProps<"div">) {
|
function CardAction({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
data-slot="card-action"
|
data-slot="card-action"
|
||||||
className={cn(
|
className={cn(
|
||||||
"col-start-2 row-span-2 row-start-1 self-start justify-self-end",
|
"col-start-2 row-span-2 row-start-1 self-start justify-self-end",
|
||||||
className
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
|
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
data-slot="card-content"
|
data-slot="card-content"
|
||||||
className={cn("px-6", className)}
|
className={cn("px-6", className)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
|
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
data-slot="card-footer"
|
data-slot="card-footer"
|
||||||
className={cn("flex items-center px-6 [.border-t]:pt-6", className)}
|
className={cn("flex items-center px-6 [.border-t]:pt-6", className)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export {
|
export {
|
||||||
Card,
|
Card,
|
||||||
CardHeader,
|
CardHeader,
|
||||||
CardFooter,
|
CardFooter,
|
||||||
CardTitle,
|
CardTitle,
|
||||||
CardAction,
|
CardAction,
|
||||||
CardDescription,
|
CardDescription,
|
||||||
CardContent,
|
CardContent,
|
||||||
}
|
};
|
||||||
|
|||||||
@@ -1,32 +1,32 @@
|
|||||||
"use client"
|
"use client";
|
||||||
|
|
||||||
import * as React from "react"
|
import * as React from "react";
|
||||||
import * as CheckboxPrimitive from "@radix-ui/react-checkbox"
|
import * as CheckboxPrimitive from "@radix-ui/react-checkbox";
|
||||||
import { CheckIcon } from "lucide-react"
|
import { CheckIcon } from "lucide-react";
|
||||||
|
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
function Checkbox({
|
function Checkbox({
|
||||||
className,
|
className,
|
||||||
...props
|
...props
|
||||||
}: React.ComponentProps<typeof CheckboxPrimitive.Root>) {
|
}: React.ComponentProps<typeof CheckboxPrimitive.Root>) {
|
||||||
return (
|
return (
|
||||||
<CheckboxPrimitive.Root
|
<CheckboxPrimitive.Root
|
||||||
data-slot="checkbox"
|
data-slot="checkbox"
|
||||||
className={cn(
|
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",
|
"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
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
<CheckboxPrimitive.Indicator
|
<CheckboxPrimitive.Indicator
|
||||||
data-slot="checkbox-indicator"
|
data-slot="checkbox-indicator"
|
||||||
className="flex items-center justify-center text-current transition-none"
|
className="flex items-center justify-center text-current transition-none"
|
||||||
>
|
>
|
||||||
<CheckIcon className="size-3.5" />
|
<CheckIcon className="size-3.5" />
|
||||||
</CheckboxPrimitive.Indicator>
|
</CheckboxPrimitive.Indicator>
|
||||||
</CheckboxPrimitive.Root>
|
</CheckboxPrimitive.Root>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export { Checkbox }
|
export { Checkbox };
|
||||||
|
|||||||
@@ -1,143 +1,143 @@
|
|||||||
"use client"
|
"use client";
|
||||||
|
|
||||||
import * as React from "react"
|
import * as React from "react";
|
||||||
import * as DialogPrimitive from "@radix-ui/react-dialog"
|
import * as DialogPrimitive from "@radix-ui/react-dialog";
|
||||||
import { XIcon } from "lucide-react"
|
import { XIcon } from "lucide-react";
|
||||||
|
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
function Dialog({
|
function Dialog({
|
||||||
...props
|
...props
|
||||||
}: React.ComponentProps<typeof DialogPrimitive.Root>) {
|
}: React.ComponentProps<typeof DialogPrimitive.Root>) {
|
||||||
return <DialogPrimitive.Root data-slot="dialog" {...props} />
|
return <DialogPrimitive.Root data-slot="dialog" {...props} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
function DialogTrigger({
|
function DialogTrigger({
|
||||||
...props
|
...props
|
||||||
}: React.ComponentProps<typeof DialogPrimitive.Trigger>) {
|
}: React.ComponentProps<typeof DialogPrimitive.Trigger>) {
|
||||||
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />
|
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
function DialogPortal({
|
function DialogPortal({
|
||||||
...props
|
...props
|
||||||
}: React.ComponentProps<typeof DialogPrimitive.Portal>) {
|
}: React.ComponentProps<typeof DialogPrimitive.Portal>) {
|
||||||
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />
|
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
function DialogClose({
|
function DialogClose({
|
||||||
...props
|
...props
|
||||||
}: React.ComponentProps<typeof DialogPrimitive.Close>) {
|
}: React.ComponentProps<typeof DialogPrimitive.Close>) {
|
||||||
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />
|
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
function DialogOverlay({
|
function DialogOverlay({
|
||||||
className,
|
className,
|
||||||
...props
|
...props
|
||||||
}: React.ComponentProps<typeof DialogPrimitive.Overlay>) {
|
}: React.ComponentProps<typeof DialogPrimitive.Overlay>) {
|
||||||
return (
|
return (
|
||||||
<DialogPrimitive.Overlay
|
<DialogPrimitive.Overlay
|
||||||
data-slot="dialog-overlay"
|
data-slot="dialog-overlay"
|
||||||
className={cn(
|
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",
|
"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
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function DialogContent({
|
function DialogContent({
|
||||||
className,
|
className,
|
||||||
children,
|
children,
|
||||||
showCloseButton = true,
|
showCloseButton = true,
|
||||||
...props
|
...props
|
||||||
}: React.ComponentProps<typeof DialogPrimitive.Content> & {
|
}: React.ComponentProps<typeof DialogPrimitive.Content> & {
|
||||||
showCloseButton?: boolean
|
showCloseButton?: boolean;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<DialogPortal data-slot="dialog-portal">
|
<DialogPortal data-slot="dialog-portal">
|
||||||
<DialogOverlay />
|
<DialogOverlay />
|
||||||
<DialogPrimitive.Content
|
<DialogPrimitive.Content
|
||||||
data-slot="dialog-content"
|
data-slot="dialog-content"
|
||||||
className={cn(
|
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",
|
"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
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
{showCloseButton && (
|
{showCloseButton && (
|
||||||
<DialogPrimitive.Close
|
<DialogPrimitive.Close
|
||||||
data-slot="dialog-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"
|
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 />
|
<XIcon />
|
||||||
<span className="sr-only">Close</span>
|
<span className="sr-only">Close</span>
|
||||||
</DialogPrimitive.Close>
|
</DialogPrimitive.Close>
|
||||||
)}
|
)}
|
||||||
</DialogPrimitive.Content>
|
</DialogPrimitive.Content>
|
||||||
</DialogPortal>
|
</DialogPortal>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
|
function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
data-slot="dialog-header"
|
data-slot="dialog-header"
|
||||||
className={cn("flex flex-col gap-2 text-center sm:text-left", className)}
|
className={cn("flex flex-col gap-2 text-center sm:text-left", className)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function DialogFooter({ className, ...props }: React.ComponentProps<"div">) {
|
function DialogFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
data-slot="dialog-footer"
|
data-slot="dialog-footer"
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex flex-col-reverse gap-2 sm:flex-row sm:justify-end",
|
"flex flex-col-reverse gap-2 sm:flex-row sm:justify-end",
|
||||||
className
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function DialogTitle({
|
function DialogTitle({
|
||||||
className,
|
className,
|
||||||
...props
|
...props
|
||||||
}: React.ComponentProps<typeof DialogPrimitive.Title>) {
|
}: React.ComponentProps<typeof DialogPrimitive.Title>) {
|
||||||
return (
|
return (
|
||||||
<DialogPrimitive.Title
|
<DialogPrimitive.Title
|
||||||
data-slot="dialog-title"
|
data-slot="dialog-title"
|
||||||
className={cn("text-lg leading-none font-semibold", className)}
|
className={cn("text-lg leading-none font-semibold", className)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function DialogDescription({
|
function DialogDescription({
|
||||||
className,
|
className,
|
||||||
...props
|
...props
|
||||||
}: React.ComponentProps<typeof DialogPrimitive.Description>) {
|
}: React.ComponentProps<typeof DialogPrimitive.Description>) {
|
||||||
return (
|
return (
|
||||||
<DialogPrimitive.Description
|
<DialogPrimitive.Description
|
||||||
data-slot="dialog-description"
|
data-slot="dialog-description"
|
||||||
className={cn("text-muted-foreground text-sm", className)}
|
className={cn("text-muted-foreground text-sm", className)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export {
|
export {
|
||||||
Dialog,
|
Dialog,
|
||||||
DialogClose,
|
DialogClose,
|
||||||
DialogContent,
|
DialogContent,
|
||||||
DialogDescription,
|
DialogDescription,
|
||||||
DialogFooter,
|
DialogFooter,
|
||||||
DialogHeader,
|
DialogHeader,
|
||||||
DialogOverlay,
|
DialogOverlay,
|
||||||
DialogPortal,
|
DialogPortal,
|
||||||
DialogTitle,
|
DialogTitle,
|
||||||
DialogTrigger,
|
DialogTrigger,
|
||||||
}
|
};
|
||||||
|
|||||||
@@ -1,257 +1,257 @@
|
|||||||
"use client"
|
"use client";
|
||||||
|
|
||||||
import * as React from "react"
|
import * as React from "react";
|
||||||
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu"
|
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu";
|
||||||
import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react"
|
import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react";
|
||||||
|
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
function DropdownMenu({
|
function DropdownMenu({
|
||||||
...props
|
...props
|
||||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Root>) {
|
}: React.ComponentProps<typeof DropdownMenuPrimitive.Root>) {
|
||||||
return <DropdownMenuPrimitive.Root data-slot="dropdown-menu" {...props} />
|
return <DropdownMenuPrimitive.Root data-slot="dropdown-menu" {...props} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
function DropdownMenuPortal({
|
function DropdownMenuPortal({
|
||||||
...props
|
...props
|
||||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Portal>) {
|
}: React.ComponentProps<typeof DropdownMenuPrimitive.Portal>) {
|
||||||
return (
|
return (
|
||||||
<DropdownMenuPrimitive.Portal data-slot="dropdown-menu-portal" {...props} />
|
<DropdownMenuPrimitive.Portal data-slot="dropdown-menu-portal" {...props} />
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function DropdownMenuTrigger({
|
function DropdownMenuTrigger({
|
||||||
...props
|
...props
|
||||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Trigger>) {
|
}: React.ComponentProps<typeof DropdownMenuPrimitive.Trigger>) {
|
||||||
return (
|
return (
|
||||||
<DropdownMenuPrimitive.Trigger
|
<DropdownMenuPrimitive.Trigger
|
||||||
data-slot="dropdown-menu-trigger"
|
data-slot="dropdown-menu-trigger"
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function DropdownMenuContent({
|
function DropdownMenuContent({
|
||||||
className,
|
className,
|
||||||
sideOffset = 4,
|
sideOffset = 4,
|
||||||
...props
|
...props
|
||||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Content>) {
|
}: React.ComponentProps<typeof DropdownMenuPrimitive.Content>) {
|
||||||
return (
|
return (
|
||||||
<DropdownMenuPrimitive.Portal>
|
<DropdownMenuPrimitive.Portal>
|
||||||
<DropdownMenuPrimitive.Content
|
<DropdownMenuPrimitive.Content
|
||||||
data-slot="dropdown-menu-content"
|
data-slot="dropdown-menu-content"
|
||||||
sideOffset={sideOffset}
|
sideOffset={sideOffset}
|
||||||
className={cn(
|
className={cn(
|
||||||
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 max-h-(--radix-dropdown-menu-content-available-height) min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border p-1 shadow-md",
|
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 max-h-(--radix-dropdown-menu-content-available-height) min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border p-1 shadow-md",
|
||||||
className
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
</DropdownMenuPrimitive.Portal>
|
</DropdownMenuPrimitive.Portal>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function DropdownMenuGroup({
|
function DropdownMenuGroup({
|
||||||
...props
|
...props
|
||||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Group>) {
|
}: React.ComponentProps<typeof DropdownMenuPrimitive.Group>) {
|
||||||
return (
|
return (
|
||||||
<DropdownMenuPrimitive.Group data-slot="dropdown-menu-group" {...props} />
|
<DropdownMenuPrimitive.Group data-slot="dropdown-menu-group" {...props} />
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function DropdownMenuItem({
|
function DropdownMenuItem({
|
||||||
className,
|
className,
|
||||||
inset,
|
inset,
|
||||||
variant = "default",
|
variant = "default",
|
||||||
...props
|
...props
|
||||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Item> & {
|
}: React.ComponentProps<typeof DropdownMenuPrimitive.Item> & {
|
||||||
inset?: boolean
|
inset?: boolean;
|
||||||
variant?: "default" | "destructive"
|
variant?: "default" | "destructive";
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<DropdownMenuPrimitive.Item
|
<DropdownMenuPrimitive.Item
|
||||||
data-slot="dropdown-menu-item"
|
data-slot="dropdown-menu-item"
|
||||||
data-inset={inset}
|
data-inset={inset}
|
||||||
data-variant={variant}
|
data-variant={variant}
|
||||||
className={cn(
|
className={cn(
|
||||||
"focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:!text-destructive [&_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]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
"focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:!text-destructive [&_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]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||||
className
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function DropdownMenuCheckboxItem({
|
function DropdownMenuCheckboxItem({
|
||||||
className,
|
className,
|
||||||
children,
|
children,
|
||||||
checked,
|
checked,
|
||||||
...props
|
...props
|
||||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.CheckboxItem>) {
|
}: React.ComponentProps<typeof DropdownMenuPrimitive.CheckboxItem>) {
|
||||||
return (
|
return (
|
||||||
<DropdownMenuPrimitive.CheckboxItem
|
<DropdownMenuPrimitive.CheckboxItem
|
||||||
data-slot="dropdown-menu-checkbox-item"
|
data-slot="dropdown-menu-checkbox-item"
|
||||||
className={cn(
|
className={cn(
|
||||||
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||||
className
|
className,
|
||||||
)}
|
)}
|
||||||
checked={checked}
|
checked={checked}
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
|
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
|
||||||
<DropdownMenuPrimitive.ItemIndicator>
|
<DropdownMenuPrimitive.ItemIndicator>
|
||||||
<CheckIcon className="size-4" />
|
<CheckIcon className="size-4" />
|
||||||
</DropdownMenuPrimitive.ItemIndicator>
|
</DropdownMenuPrimitive.ItemIndicator>
|
||||||
</span>
|
</span>
|
||||||
{children}
|
{children}
|
||||||
</DropdownMenuPrimitive.CheckboxItem>
|
</DropdownMenuPrimitive.CheckboxItem>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function DropdownMenuRadioGroup({
|
function DropdownMenuRadioGroup({
|
||||||
...props
|
...props
|
||||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioGroup>) {
|
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioGroup>) {
|
||||||
return (
|
return (
|
||||||
<DropdownMenuPrimitive.RadioGroup
|
<DropdownMenuPrimitive.RadioGroup
|
||||||
data-slot="dropdown-menu-radio-group"
|
data-slot="dropdown-menu-radio-group"
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function DropdownMenuRadioItem({
|
function DropdownMenuRadioItem({
|
||||||
className,
|
className,
|
||||||
children,
|
children,
|
||||||
...props
|
...props
|
||||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioItem>) {
|
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioItem>) {
|
||||||
return (
|
return (
|
||||||
<DropdownMenuPrimitive.RadioItem
|
<DropdownMenuPrimitive.RadioItem
|
||||||
data-slot="dropdown-menu-radio-item"
|
data-slot="dropdown-menu-radio-item"
|
||||||
className={cn(
|
className={cn(
|
||||||
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||||
className
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
|
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
|
||||||
<DropdownMenuPrimitive.ItemIndicator>
|
<DropdownMenuPrimitive.ItemIndicator>
|
||||||
<CircleIcon className="size-2 fill-current" />
|
<CircleIcon className="size-2 fill-current" />
|
||||||
</DropdownMenuPrimitive.ItemIndicator>
|
</DropdownMenuPrimitive.ItemIndicator>
|
||||||
</span>
|
</span>
|
||||||
{children}
|
{children}
|
||||||
</DropdownMenuPrimitive.RadioItem>
|
</DropdownMenuPrimitive.RadioItem>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function DropdownMenuLabel({
|
function DropdownMenuLabel({
|
||||||
className,
|
className,
|
||||||
inset,
|
inset,
|
||||||
...props
|
...props
|
||||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Label> & {
|
}: React.ComponentProps<typeof DropdownMenuPrimitive.Label> & {
|
||||||
inset?: boolean
|
inset?: boolean;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<DropdownMenuPrimitive.Label
|
<DropdownMenuPrimitive.Label
|
||||||
data-slot="dropdown-menu-label"
|
data-slot="dropdown-menu-label"
|
||||||
data-inset={inset}
|
data-inset={inset}
|
||||||
className={cn(
|
className={cn(
|
||||||
"px-2 py-1.5 text-sm font-medium data-[inset]:pl-8",
|
"px-2 py-1.5 text-sm font-medium data-[inset]:pl-8",
|
||||||
className
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function DropdownMenuSeparator({
|
function DropdownMenuSeparator({
|
||||||
className,
|
className,
|
||||||
...props
|
...props
|
||||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Separator>) {
|
}: React.ComponentProps<typeof DropdownMenuPrimitive.Separator>) {
|
||||||
return (
|
return (
|
||||||
<DropdownMenuPrimitive.Separator
|
<DropdownMenuPrimitive.Separator
|
||||||
data-slot="dropdown-menu-separator"
|
data-slot="dropdown-menu-separator"
|
||||||
className={cn("bg-border -mx-1 my-1 h-px", className)}
|
className={cn("bg-border -mx-1 my-1 h-px", className)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function DropdownMenuShortcut({
|
function DropdownMenuShortcut({
|
||||||
className,
|
className,
|
||||||
...props
|
...props
|
||||||
}: React.ComponentProps<"span">) {
|
}: React.ComponentProps<"span">) {
|
||||||
return (
|
return (
|
||||||
<span
|
<span
|
||||||
data-slot="dropdown-menu-shortcut"
|
data-slot="dropdown-menu-shortcut"
|
||||||
className={cn(
|
className={cn(
|
||||||
"text-muted-foreground ml-auto text-xs tracking-widest",
|
"text-muted-foreground ml-auto text-xs tracking-widest",
|
||||||
className
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function DropdownMenuSub({
|
function DropdownMenuSub({
|
||||||
...props
|
...props
|
||||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Sub>) {
|
}: React.ComponentProps<typeof DropdownMenuPrimitive.Sub>) {
|
||||||
return <DropdownMenuPrimitive.Sub data-slot="dropdown-menu-sub" {...props} />
|
return <DropdownMenuPrimitive.Sub data-slot="dropdown-menu-sub" {...props} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
function DropdownMenuSubTrigger({
|
function DropdownMenuSubTrigger({
|
||||||
className,
|
className,
|
||||||
inset,
|
inset,
|
||||||
children,
|
children,
|
||||||
...props
|
...props
|
||||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubTrigger> & {
|
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubTrigger> & {
|
||||||
inset?: boolean
|
inset?: boolean;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<DropdownMenuPrimitive.SubTrigger
|
<DropdownMenuPrimitive.SubTrigger
|
||||||
data-slot="dropdown-menu-sub-trigger"
|
data-slot="dropdown-menu-sub-trigger"
|
||||||
data-inset={inset}
|
data-inset={inset}
|
||||||
className={cn(
|
className={cn(
|
||||||
"focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground flex cursor-default items-center rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[inset]:pl-8",
|
"focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground flex cursor-default items-center rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[inset]:pl-8",
|
||||||
className
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
<ChevronRightIcon className="ml-auto size-4" />
|
<ChevronRightIcon className="ml-auto size-4" />
|
||||||
</DropdownMenuPrimitive.SubTrigger>
|
</DropdownMenuPrimitive.SubTrigger>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function DropdownMenuSubContent({
|
function DropdownMenuSubContent({
|
||||||
className,
|
className,
|
||||||
...props
|
...props
|
||||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubContent>) {
|
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubContent>) {
|
||||||
return (
|
return (
|
||||||
<DropdownMenuPrimitive.SubContent
|
<DropdownMenuPrimitive.SubContent
|
||||||
data-slot="dropdown-menu-sub-content"
|
data-slot="dropdown-menu-sub-content"
|
||||||
className={cn(
|
className={cn(
|
||||||
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-hidden rounded-md border p-1 shadow-lg",
|
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-hidden rounded-md border p-1 shadow-lg",
|
||||||
className
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export {
|
export {
|
||||||
DropdownMenu,
|
DropdownMenu,
|
||||||
DropdownMenuPortal,
|
DropdownMenuPortal,
|
||||||
DropdownMenuTrigger,
|
DropdownMenuTrigger,
|
||||||
DropdownMenuContent,
|
DropdownMenuContent,
|
||||||
DropdownMenuGroup,
|
DropdownMenuGroup,
|
||||||
DropdownMenuLabel,
|
DropdownMenuLabel,
|
||||||
DropdownMenuItem,
|
DropdownMenuItem,
|
||||||
DropdownMenuCheckboxItem,
|
DropdownMenuCheckboxItem,
|
||||||
DropdownMenuRadioGroup,
|
DropdownMenuRadioGroup,
|
||||||
DropdownMenuRadioItem,
|
DropdownMenuRadioItem,
|
||||||
DropdownMenuSeparator,
|
DropdownMenuSeparator,
|
||||||
DropdownMenuShortcut,
|
DropdownMenuShortcut,
|
||||||
DropdownMenuSub,
|
DropdownMenuSub,
|
||||||
DropdownMenuSubTrigger,
|
DropdownMenuSubTrigger,
|
||||||
DropdownMenuSubContent,
|
DropdownMenuSubContent,
|
||||||
}
|
};
|
||||||
|
|||||||
@@ -1,21 +1,21 @@
|
|||||||
import * as React from "react"
|
import * as React from "react";
|
||||||
|
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
|
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
|
||||||
return (
|
return (
|
||||||
<input
|
<input
|
||||||
type={type}
|
type={type}
|
||||||
data-slot="input"
|
data-slot="input"
|
||||||
className={cn(
|
className={cn(
|
||||||
"file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input flex h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
|
"file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input flex h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
|
||||||
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
|
"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",
|
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
|
||||||
className
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export { Input }
|
export { Input };
|
||||||
|
|||||||
@@ -1,24 +1,24 @@
|
|||||||
"use client"
|
"use client";
|
||||||
|
|
||||||
import * as React from "react"
|
import * as React from "react";
|
||||||
import * as LabelPrimitive from "@radix-ui/react-label"
|
import * as LabelPrimitive from "@radix-ui/react-label";
|
||||||
|
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
function Label({
|
function Label({
|
||||||
className,
|
className,
|
||||||
...props
|
...props
|
||||||
}: React.ComponentProps<typeof LabelPrimitive.Root>) {
|
}: React.ComponentProps<typeof LabelPrimitive.Root>) {
|
||||||
return (
|
return (
|
||||||
<LabelPrimitive.Root
|
<LabelPrimitive.Root
|
||||||
data-slot="label"
|
data-slot="label"
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50",
|
"flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50",
|
||||||
className
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export { Label }
|
export { Label };
|
||||||
|
|||||||
@@ -1,185 +1,185 @@
|
|||||||
"use client"
|
"use client";
|
||||||
|
|
||||||
import * as React from "react"
|
import * as React from "react";
|
||||||
import * as SelectPrimitive from "@radix-ui/react-select"
|
import * as SelectPrimitive from "@radix-ui/react-select";
|
||||||
import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from "lucide-react"
|
import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from "lucide-react";
|
||||||
|
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
function Select({
|
function Select({
|
||||||
...props
|
...props
|
||||||
}: React.ComponentProps<typeof SelectPrimitive.Root>) {
|
}: React.ComponentProps<typeof SelectPrimitive.Root>) {
|
||||||
return <SelectPrimitive.Root data-slot="select" {...props} />
|
return <SelectPrimitive.Root data-slot="select" {...props} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
function SelectGroup({
|
function SelectGroup({
|
||||||
...props
|
...props
|
||||||
}: React.ComponentProps<typeof SelectPrimitive.Group>) {
|
}: React.ComponentProps<typeof SelectPrimitive.Group>) {
|
||||||
return <SelectPrimitive.Group data-slot="select-group" {...props} />
|
return <SelectPrimitive.Group data-slot="select-group" {...props} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
function SelectValue({
|
function SelectValue({
|
||||||
...props
|
...props
|
||||||
}: React.ComponentProps<typeof SelectPrimitive.Value>) {
|
}: React.ComponentProps<typeof SelectPrimitive.Value>) {
|
||||||
return <SelectPrimitive.Value data-slot="select-value" {...props} />
|
return <SelectPrimitive.Value data-slot="select-value" {...props} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
function SelectTrigger({
|
function SelectTrigger({
|
||||||
className,
|
className,
|
||||||
size = "default",
|
size = "default",
|
||||||
children,
|
children,
|
||||||
...props
|
...props
|
||||||
}: React.ComponentProps<typeof SelectPrimitive.Trigger> & {
|
}: React.ComponentProps<typeof SelectPrimitive.Trigger> & {
|
||||||
size?: "sm" | "default"
|
size?: "sm" | "default";
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<SelectPrimitive.Trigger
|
<SelectPrimitive.Trigger
|
||||||
data-slot="select-trigger"
|
data-slot="select-trigger"
|
||||||
data-size={size}
|
data-size={size}
|
||||||
className={cn(
|
className={cn(
|
||||||
"border-input data-[placeholder]:text-muted-foreground [&_svg:not([class*='text-'])]:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 dark:hover:bg-input/50 flex w-fit items-center justify-between gap-2 rounded-md border bg-transparent px-3 py-2 text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
"border-input data-[placeholder]:text-muted-foreground [&_svg:not([class*='text-'])]:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 dark:hover:bg-input/50 flex w-fit items-center justify-between gap-2 rounded-md border bg-transparent px-3 py-2 text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||||
className
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
<SelectPrimitive.Icon asChild>
|
<SelectPrimitive.Icon asChild>
|
||||||
<ChevronDownIcon className="size-4 opacity-50" />
|
<ChevronDownIcon className="size-4 opacity-50" />
|
||||||
</SelectPrimitive.Icon>
|
</SelectPrimitive.Icon>
|
||||||
</SelectPrimitive.Trigger>
|
</SelectPrimitive.Trigger>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function SelectContent({
|
function SelectContent({
|
||||||
className,
|
className,
|
||||||
children,
|
children,
|
||||||
position = "popper",
|
position = "popper",
|
||||||
...props
|
...props
|
||||||
}: React.ComponentProps<typeof SelectPrimitive.Content>) {
|
}: React.ComponentProps<typeof SelectPrimitive.Content>) {
|
||||||
return (
|
return (
|
||||||
<SelectPrimitive.Portal>
|
<SelectPrimitive.Portal>
|
||||||
<SelectPrimitive.Content
|
<SelectPrimitive.Content
|
||||||
data-slot="select-content"
|
data-slot="select-content"
|
||||||
className={cn(
|
className={cn(
|
||||||
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 relative z-50 max-h-(--radix-select-content-available-height) min-w-[8rem] origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border shadow-md",
|
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 relative z-50 max-h-(--radix-select-content-available-height) min-w-[8rem] origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border shadow-md",
|
||||||
position === "popper" &&
|
position === "popper" &&
|
||||||
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
|
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
|
||||||
className
|
className,
|
||||||
)}
|
)}
|
||||||
position={position}
|
position={position}
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
<SelectScrollUpButton />
|
<SelectScrollUpButton />
|
||||||
<SelectPrimitive.Viewport
|
<SelectPrimitive.Viewport
|
||||||
className={cn(
|
className={cn(
|
||||||
"p-1",
|
"p-1",
|
||||||
position === "popper" &&
|
position === "popper" &&
|
||||||
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)] scroll-my-1"
|
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)] scroll-my-1",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
</SelectPrimitive.Viewport>
|
</SelectPrimitive.Viewport>
|
||||||
<SelectScrollDownButton />
|
<SelectScrollDownButton />
|
||||||
</SelectPrimitive.Content>
|
</SelectPrimitive.Content>
|
||||||
</SelectPrimitive.Portal>
|
</SelectPrimitive.Portal>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function SelectLabel({
|
function SelectLabel({
|
||||||
className,
|
className,
|
||||||
...props
|
...props
|
||||||
}: React.ComponentProps<typeof SelectPrimitive.Label>) {
|
}: React.ComponentProps<typeof SelectPrimitive.Label>) {
|
||||||
return (
|
return (
|
||||||
<SelectPrimitive.Label
|
<SelectPrimitive.Label
|
||||||
data-slot="select-label"
|
data-slot="select-label"
|
||||||
className={cn("text-muted-foreground px-2 py-1.5 text-xs", className)}
|
className={cn("text-muted-foreground px-2 py-1.5 text-xs", className)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function SelectItem({
|
function SelectItem({
|
||||||
className,
|
className,
|
||||||
children,
|
children,
|
||||||
...props
|
...props
|
||||||
}: React.ComponentProps<typeof SelectPrimitive.Item>) {
|
}: React.ComponentProps<typeof SelectPrimitive.Item>) {
|
||||||
return (
|
return (
|
||||||
<SelectPrimitive.Item
|
<SelectPrimitive.Item
|
||||||
data-slot="select-item"
|
data-slot="select-item"
|
||||||
className={cn(
|
className={cn(
|
||||||
"focus:bg-accent focus:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex w-full cursor-default items-center gap-2 rounded-sm py-1.5 pr-8 pl-2 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
|
"focus:bg-accent focus:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex w-full cursor-default items-center gap-2 rounded-sm py-1.5 pr-8 pl-2 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
|
||||||
className
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
<span className="absolute right-2 flex size-3.5 items-center justify-center">
|
<span className="absolute right-2 flex size-3.5 items-center justify-center">
|
||||||
<SelectPrimitive.ItemIndicator>
|
<SelectPrimitive.ItemIndicator>
|
||||||
<CheckIcon className="size-4" />
|
<CheckIcon className="size-4" />
|
||||||
</SelectPrimitive.ItemIndicator>
|
</SelectPrimitive.ItemIndicator>
|
||||||
</span>
|
</span>
|
||||||
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
|
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
|
||||||
</SelectPrimitive.Item>
|
</SelectPrimitive.Item>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function SelectSeparator({
|
function SelectSeparator({
|
||||||
className,
|
className,
|
||||||
...props
|
...props
|
||||||
}: React.ComponentProps<typeof SelectPrimitive.Separator>) {
|
}: React.ComponentProps<typeof SelectPrimitive.Separator>) {
|
||||||
return (
|
return (
|
||||||
<SelectPrimitive.Separator
|
<SelectPrimitive.Separator
|
||||||
data-slot="select-separator"
|
data-slot="select-separator"
|
||||||
className={cn("bg-border pointer-events-none -mx-1 my-1 h-px", className)}
|
className={cn("bg-border pointer-events-none -mx-1 my-1 h-px", className)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function SelectScrollUpButton({
|
function SelectScrollUpButton({
|
||||||
className,
|
className,
|
||||||
...props
|
...props
|
||||||
}: React.ComponentProps<typeof SelectPrimitive.ScrollUpButton>) {
|
}: React.ComponentProps<typeof SelectPrimitive.ScrollUpButton>) {
|
||||||
return (
|
return (
|
||||||
<SelectPrimitive.ScrollUpButton
|
<SelectPrimitive.ScrollUpButton
|
||||||
data-slot="select-scroll-up-button"
|
data-slot="select-scroll-up-button"
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex cursor-default items-center justify-center py-1",
|
"flex cursor-default items-center justify-center py-1",
|
||||||
className
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
<ChevronUpIcon className="size-4" />
|
<ChevronUpIcon className="size-4" />
|
||||||
</SelectPrimitive.ScrollUpButton>
|
</SelectPrimitive.ScrollUpButton>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function SelectScrollDownButton({
|
function SelectScrollDownButton({
|
||||||
className,
|
className,
|
||||||
...props
|
...props
|
||||||
}: React.ComponentProps<typeof SelectPrimitive.ScrollDownButton>) {
|
}: React.ComponentProps<typeof SelectPrimitive.ScrollDownButton>) {
|
||||||
return (
|
return (
|
||||||
<SelectPrimitive.ScrollDownButton
|
<SelectPrimitive.ScrollDownButton
|
||||||
data-slot="select-scroll-down-button"
|
data-slot="select-scroll-down-button"
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex cursor-default items-center justify-center py-1",
|
"flex cursor-default items-center justify-center py-1",
|
||||||
className
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
<ChevronDownIcon className="size-4" />
|
<ChevronDownIcon className="size-4" />
|
||||||
</SelectPrimitive.ScrollDownButton>
|
</SelectPrimitive.ScrollDownButton>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export {
|
export {
|
||||||
Select,
|
Select,
|
||||||
SelectContent,
|
SelectContent,
|
||||||
SelectGroup,
|
SelectGroup,
|
||||||
SelectItem,
|
SelectItem,
|
||||||
SelectLabel,
|
SelectLabel,
|
||||||
SelectScrollDownButton,
|
SelectScrollDownButton,
|
||||||
SelectScrollUpButton,
|
SelectScrollUpButton,
|
||||||
SelectSeparator,
|
SelectSeparator,
|
||||||
SelectTrigger,
|
SelectTrigger,
|
||||||
SelectValue,
|
SelectValue,
|
||||||
}
|
};
|
||||||
|
|||||||
@@ -1,25 +1,25 @@
|
|||||||
"use client"
|
"use client";
|
||||||
|
|
||||||
import { useTheme } from "next-themes"
|
import { useTheme } from "next-themes";
|
||||||
import { Toaster as Sonner, ToasterProps } from "sonner"
|
import { Toaster as Sonner, ToasterProps } from "sonner";
|
||||||
|
|
||||||
const Toaster = ({ ...props }: ToasterProps) => {
|
const Toaster = ({ ...props }: ToasterProps) => {
|
||||||
const { theme = "system" } = useTheme()
|
const { theme = "system" } = useTheme();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Sonner
|
<Sonner
|
||||||
theme={theme as ToasterProps["theme"]}
|
theme={theme as ToasterProps["theme"]}
|
||||||
className="toaster group"
|
className="toaster group"
|
||||||
style={
|
style={
|
||||||
{
|
{
|
||||||
"--normal-bg": "var(--popover)",
|
"--normal-bg": "var(--popover)",
|
||||||
"--normal-text": "var(--popover-foreground)",
|
"--normal-text": "var(--popover-foreground)",
|
||||||
"--normal-border": "var(--border)",
|
"--normal-border": "var(--border)",
|
||||||
} as React.CSSProperties
|
} as React.CSSProperties
|
||||||
}
|
}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
);
|
||||||
}
|
};
|
||||||
|
|
||||||
export { Toaster }
|
export { Toaster };
|
||||||
|
|||||||
@@ -1,18 +1,18 @@
|
|||||||
import * as React from "react"
|
import * as React from "react";
|
||||||
|
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
|
function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
|
||||||
return (
|
return (
|
||||||
<textarea
|
<textarea
|
||||||
data-slot="textarea"
|
data-slot="textarea"
|
||||||
className={cn(
|
className={cn(
|
||||||
"border-input placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 flex field-sizing-content min-h-16 w-full rounded-md border bg-transparent px-3 py-2 text-base shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
|
"border-input placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 flex field-sizing-content min-h-16 w-full rounded-md border bg-transparent px-3 py-2 text-base shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
|
||||||
className
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export { Textarea }
|
export { Textarea };
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
import { drizzle } from 'drizzle-orm/postgres-js';
|
import { drizzle } from "drizzle-orm/postgres-js";
|
||||||
import postgres from 'postgres';
|
import postgres from "postgres";
|
||||||
import * as schema from './schema';
|
import * as schema from "./schema";
|
||||||
|
|
||||||
const connectionString = process.env.DATABASE_URL!;
|
const connectionString = process.env.DATABASE_URL!;
|
||||||
|
|
||||||
const client = postgres(connectionString, {
|
const client = postgres(connectionString, {
|
||||||
prepare: false,
|
prepare: false,
|
||||||
connect_timeout: 30,
|
connect_timeout: 30,
|
||||||
idle_timeout: 30,
|
idle_timeout: 30,
|
||||||
});
|
});
|
||||||
export const db = drizzle(client, { schema });
|
export const db = drizzle(client, { schema });
|
||||||
|
|||||||
@@ -1,47 +1,51 @@
|
|||||||
import { pgTable, text, timestamp, boolean } from 'drizzle-orm/pg-core';
|
import { pgTable, text, timestamp, boolean } from "drizzle-orm/pg-core";
|
||||||
|
|
||||||
export const user = pgTable('user', {
|
export const user = pgTable("user", {
|
||||||
id: text('id').primaryKey(),
|
id: text("id").primaryKey(),
|
||||||
name: text('name'),
|
name: text("name"),
|
||||||
email: text('email').notNull().unique(),
|
email: text("email").notNull().unique(),
|
||||||
emailVerified: boolean('emailVerified').default(false),
|
emailVerified: boolean("emailVerified").default(false),
|
||||||
image: text('image'),
|
image: text("image"),
|
||||||
createdAt: timestamp('createdAt').defaultNow(),
|
createdAt: timestamp("createdAt").defaultNow(),
|
||||||
updatedAt: timestamp('updatedAt').defaultNow(),
|
updatedAt: timestamp("updatedAt").defaultNow(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const session = pgTable('session', {
|
export const session = pgTable("session", {
|
||||||
id: text('id').primaryKey(),
|
id: text("id").primaryKey(),
|
||||||
expiresAt: timestamp('expiresAt').notNull(),
|
expiresAt: timestamp("expiresAt").notNull(),
|
||||||
token: text('token').notNull().unique(),
|
token: text("token").notNull().unique(),
|
||||||
createdAt: timestamp('createdAt').defaultNow(),
|
createdAt: timestamp("createdAt").defaultNow(),
|
||||||
updatedAt: timestamp('updatedAt').defaultNow(),
|
updatedAt: timestamp("updatedAt").defaultNow(),
|
||||||
ipAddress: text('ipAddress'),
|
ipAddress: text("ipAddress"),
|
||||||
userAgent: text('userAgent'),
|
userAgent: text("userAgent"),
|
||||||
userId: text('userId').notNull().references(() => user.id, { onDelete: 'cascade' }),
|
userId: text("userId")
|
||||||
|
.notNull()
|
||||||
|
.references(() => user.id, { onDelete: "cascade" }),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const account = pgTable('account', {
|
export const account = pgTable("account", {
|
||||||
id: text('id').primaryKey(),
|
id: text("id").primaryKey(),
|
||||||
accountId: text('accountId').notNull(),
|
accountId: text("accountId").notNull(),
|
||||||
providerId: text('providerId').notNull(),
|
providerId: text("providerId").notNull(),
|
||||||
userId: text('userId').notNull().references(() => user.id, { onDelete: 'cascade' }),
|
userId: text("userId")
|
||||||
accessToken: text('accessToken'),
|
.notNull()
|
||||||
refreshToken: text('refreshToken'),
|
.references(() => user.id, { onDelete: "cascade" }),
|
||||||
accessTokenExpiresAt: timestamp('accessTokenExpiresAt'),
|
accessToken: text("accessToken"),
|
||||||
refreshTokenExpiresAt: timestamp('refreshTokenExpiresAt'),
|
refreshToken: text("refreshToken"),
|
||||||
scope: text('scope'),
|
accessTokenExpiresAt: timestamp("accessTokenExpiresAt"),
|
||||||
idToken: text('idToken'),
|
refreshTokenExpiresAt: timestamp("refreshTokenExpiresAt"),
|
||||||
password: text('password'),
|
scope: text("scope"),
|
||||||
createdAt: timestamp('createdAt').defaultNow(),
|
idToken: text("idToken"),
|
||||||
updatedAt: timestamp('updatedAt').defaultNow(),
|
password: text("password"),
|
||||||
|
createdAt: timestamp("createdAt").defaultNow(),
|
||||||
|
updatedAt: timestamp("updatedAt").defaultNow(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const verification = pgTable('verification', {
|
export const verification = pgTable("verification", {
|
||||||
id: text('id').primaryKey(),
|
id: text("id").primaryKey(),
|
||||||
identifier: text('identifier').notNull(),
|
identifier: text("identifier").notNull(),
|
||||||
value: text('value').notNull(),
|
value: text("value").notNull(),
|
||||||
expiresAt: timestamp('expiresAt').notNull(),
|
expiresAt: timestamp("expiresAt").notNull(),
|
||||||
createdAt: timestamp('createdAt').defaultNow(),
|
createdAt: timestamp("createdAt").defaultNow(),
|
||||||
updatedAt: timestamp('updatedAt').defaultNow(),
|
updatedAt: timestamp("updatedAt").defaultNow(),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { createAuthClient } from "better-auth/react";
|
|||||||
import { genericOAuthClient } from "better-auth/client/plugins";
|
import { genericOAuthClient } from "better-auth/client/plugins";
|
||||||
|
|
||||||
export const authClient = createAuthClient({
|
export const authClient = createAuthClient({
|
||||||
plugins: [genericOAuthClient()],
|
plugins: [genericOAuthClient()],
|
||||||
});
|
});
|
||||||
|
|
||||||
export const { useSession, signIn, signOut } = authClient;
|
export const { useSession, signIn, signOut } = authClient;
|
||||||
|
|||||||
@@ -1,62 +1,65 @@
|
|||||||
import { openDB, type IDBPDatabase } from 'idb';
|
import { openDB, type IDBPDatabase } from "idb";
|
||||||
import type { CalendarEvent } from '@/lib/types';
|
import type { CalendarEvent } from "@/lib/types";
|
||||||
|
|
||||||
const DB_NAME = 'LocalCalEvents';
|
const DB_NAME = "LocalCalEvents";
|
||||||
const DB_VERSION = 1;
|
const DB_VERSION = 1;
|
||||||
const EVENTS_STORE = 'events';
|
const EVENTS_STORE = "events";
|
||||||
|
|
||||||
let dbPromise: Promise<IDBPDatabase> | null = null;
|
let dbPromise: Promise<IDBPDatabase> | null = null;
|
||||||
|
|
||||||
function getDB() {
|
function getDB() {
|
||||||
if (!dbPromise) {
|
if (!dbPromise) {
|
||||||
dbPromise = openDB(DB_NAME, DB_VERSION, {
|
dbPromise = openDB(DB_NAME, DB_VERSION, {
|
||||||
upgrade(db) {
|
upgrade(db) {
|
||||||
if (!db.objectStoreNames.contains(EVENTS_STORE)) {
|
if (!db.objectStoreNames.contains(EVENTS_STORE)) {
|
||||||
const store = db.createObjectStore(EVENTS_STORE, { keyPath: 'id' });
|
const store = db.createObjectStore(EVENTS_STORE, { keyPath: "id" });
|
||||||
store.createIndex('start', 'start');
|
store.createIndex("start", "start");
|
||||||
store.createIndex('title', 'title');
|
store.createIndex("title", "title");
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
return dbPromise;
|
return dbPromise;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function saveEvent(event: CalendarEvent): Promise<void> {
|
export async function saveEvent(event: CalendarEvent): Promise<void> {
|
||||||
const db = await getDB();
|
const db = await getDB();
|
||||||
await db.put(EVENTS_STORE, event);
|
await db.put(EVENTS_STORE, event);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getEvents(): Promise<CalendarEvent[]> {
|
export async function getEvents(): Promise<CalendarEvent[]> {
|
||||||
const db = await getDB();
|
const db = await getDB();
|
||||||
return db.getAll(EVENTS_STORE);
|
return db.getAll(EVENTS_STORE);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getEvent(id: string): Promise<CalendarEvent | undefined> {
|
export async function getEvent(id: string): Promise<CalendarEvent | undefined> {
|
||||||
const db = await getDB();
|
const db = await getDB();
|
||||||
return db.get(EVENTS_STORE, id);
|
return db.get(EVENTS_STORE, id);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function deleteEvent(id: string): Promise<void> {
|
export async function deleteEvent(id: string): Promise<void> {
|
||||||
const db = await getDB();
|
const db = await getDB();
|
||||||
await db.delete(EVENTS_STORE, id);
|
await db.delete(EVENTS_STORE, id);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function updateEvent(event: CalendarEvent): Promise<void> {
|
export async function updateEvent(event: CalendarEvent): Promise<void> {
|
||||||
const db = await getDB();
|
const db = await getDB();
|
||||||
await db.put(EVENTS_STORE, event);
|
await db.put(EVENTS_STORE, event);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getEventsByDateRange(startDate: string, endDate: string): Promise<CalendarEvent[]> {
|
export async function getEventsByDateRange(
|
||||||
const db = await getDB();
|
startDate: string,
|
||||||
const tx = db.transaction(EVENTS_STORE, 'readonly');
|
endDate: string,
|
||||||
const index = tx.store.index('start');
|
): Promise<CalendarEvent[]> {
|
||||||
const events = await index.getAll(IDBKeyRange.bound(startDate, endDate));
|
const db = await getDB();
|
||||||
await tx.done;
|
const tx = db.transaction(EVENTS_STORE, "readonly");
|
||||||
return events;
|
const index = tx.store.index("start");
|
||||||
|
const events = await index.getAll(IDBKeyRange.bound(startDate, endDate));
|
||||||
|
await tx.done;
|
||||||
|
return events;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function clearEvents(): Promise<void> {
|
export async function clearEvents(): Promise<void> {
|
||||||
const db = await getDB();
|
const db = await getDB();
|
||||||
await db.clear(EVENTS_STORE);
|
await db.clear(EVENTS_STORE);
|
||||||
}
|
}
|
||||||
|
|||||||
9
src/lib/openrouter-client.ts
Normal file
9
src/lib/openrouter-client.ts
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
import { OpenRouter } from "@openrouter/sdk";
|
||||||
|
|
||||||
|
if (!process.env.OPENROUTER_API_KEY) {
|
||||||
|
throw new Error("OPENROUTER_API_KEY environment variable is not set");
|
||||||
|
}
|
||||||
|
|
||||||
|
export const openRouterClient = new OpenRouter({
|
||||||
|
apiKey: process.env.OPENROUTER_API_KEY,
|
||||||
|
});
|
||||||
@@ -1,23 +1,30 @@
|
|||||||
// RFC 5545 (iCalendar) Recurrence Rule types
|
// RFC 5545 (iCalendar) Recurrence Rule types
|
||||||
// Based on the iCalendar specification for RRULE
|
// Based on the iCalendar specification for RRULE
|
||||||
|
|
||||||
export type Frequency = 'SECONDLY' | 'MINUTELY' | 'HOURLY' | 'DAILY' | 'WEEKLY' | 'MONTHLY' | 'YEARLY'
|
export type Frequency =
|
||||||
|
| "SECONDLY"
|
||||||
|
| "MINUTELY"
|
||||||
|
| "HOURLY"
|
||||||
|
| "DAILY"
|
||||||
|
| "WEEKLY"
|
||||||
|
| "MONTHLY"
|
||||||
|
| "YEARLY";
|
||||||
|
|
||||||
export type Weekday = 'SU' | 'MO' | 'TU' | 'WE' | 'TH' | 'FR' | 'SA'
|
export type Weekday = "SU" | "MO" | "TU" | "WE" | "TH" | "FR" | "SA";
|
||||||
|
|
||||||
export interface RecurrenceRule {
|
export interface RecurrenceRule {
|
||||||
freq: Frequency
|
freq: Frequency;
|
||||||
until?: string // ISO 8601 date string
|
until?: string; // ISO 8601 date string
|
||||||
count?: number
|
count?: number;
|
||||||
interval?: number
|
interval?: number;
|
||||||
bySecond?: number[]
|
bySecond?: number[];
|
||||||
byMinute?: number[]
|
byMinute?: number[];
|
||||||
byHour?: number[]
|
byHour?: number[];
|
||||||
byDay?: string[]
|
byDay?: string[];
|
||||||
byMonthDay?: number[]
|
byMonthDay?: number[];
|
||||||
byYearDay?: number[]
|
byYearDay?: number[];
|
||||||
byWeekNo?: number[]
|
byWeekNo?: number[];
|
||||||
byMonth?: number[]
|
byMonth?: number[];
|
||||||
bySetPos?: number[]
|
bySetPos?: number[];
|
||||||
wkst?: Weekday
|
wkst?: Weekday;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { clsx, type ClassValue } from "clsx"
|
import { clsx, type ClassValue } from "clsx";
|
||||||
import { twMerge } from "tailwind-merge"
|
import { twMerge } from "tailwind-merge";
|
||||||
|
|
||||||
export function cn(...inputs: ClassValue[]) {
|
export function cn(...inputs: ClassValue[]) {
|
||||||
return twMerge(clsx(inputs))
|
return twMerge(clsx(inputs));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,27 +1,27 @@
|
|||||||
{
|
{
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"target": "ES2017",
|
"target": "ES2017",
|
||||||
"lib": ["dom", "dom.iterable", "esnext"],
|
"lib": ["dom", "dom.iterable", "esnext"],
|
||||||
"allowJs": true,
|
"allowJs": true,
|
||||||
"skipLibCheck": true,
|
"skipLibCheck": true,
|
||||||
"strict": true,
|
"strict": true,
|
||||||
"noEmit": true,
|
"noEmit": true,
|
||||||
"esModuleInterop": true,
|
"esModuleInterop": true,
|
||||||
"module": "esnext",
|
"module": "esnext",
|
||||||
"moduleResolution": "bundler",
|
"moduleResolution": "bundler",
|
||||||
"resolveJsonModule": true,
|
"resolveJsonModule": true,
|
||||||
"isolatedModules": true,
|
"isolatedModules": true,
|
||||||
"jsx": "preserve",
|
"jsx": "preserve",
|
||||||
"incremental": true,
|
"incremental": true,
|
||||||
"plugins": [
|
"plugins": [
|
||||||
{
|
{
|
||||||
"name": "next"
|
"name": "next"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"paths": {
|
"paths": {
|
||||||
"@/*": ["./src/*"]
|
"@/*": ["./src/*"]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
|
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
|
||||||
"exclude": ["node_modules"]
|
"exclude": ["node_modules"]
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user