Compare commits

...

18 Commits

Author SHA1 Message Date
b4c03ff25e style(ui): standardize UI component file formatting 2026-04-07 08:10:13 -04:00
fd5716f39e style(components): standardize main component file formatting 2026-04-07 08:10:05 -04:00
954e73c007 style(app): standardize app page file formatting 2026-04-07 08:09:56 -04:00
48ef4f60df style(api): standardize API route file formatting 2026-04-07 08:09:49 -04:00
e39ba6be97 style(lib): standardize utils file formatting 2026-04-07 08:09:43 -04:00
3b7c246a47 style(lib): standardize rfc5545-types file formatting 2026-04-07 08:09:40 -04:00
5be55cec7c style(lib): standardize events-db file formatting 2026-04-07 08:09:36 -04:00
dab77befc2 style(auth): standardize auth file formatting 2026-04-07 08:09:32 -04:00
076cf8acd0 style(db): standardize database source file formatting 2026-04-07 08:09:26 -04:00
ae8d547486 style(public): standardize manifest.json formatting 2026-04-07 08:09:19 -04:00
3d9e2452c4 style(db): standardize database migration file formatting 2026-04-07 08:09:15 -04:00
db9d6399dd style(config): standardize configuration file formatting 2026-04-07 08:09:09 -04:00
a897e8ead1 feat(lib): add OpenRouter client implementation 2026-04-07 08:08:58 -04:00
c3e3018018 feat(deps): add @openrouter/sdk dependency 2026-04-07 08:08:51 -04:00
be389c6cfa style(skills): standardize utility-types.ts formatting 2026-04-07 08:08:44 -04:00
ada8e03a04 chore(config): add OpenRouterTeam to skill selector repos 2026-04-07 08:08:36 -04:00
956de68591 style(skills): standardize skill metadata JSON formatting 2026-04-07 08:08:28 -04:00
e25f917b9a chore(skills): add create-agent and typescript-sdk skills 2026-04-07 08:08:01 -04:00
73 changed files with 5723 additions and 3381 deletions

View File

@@ -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"
} }

View File

@@ -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"
} }

View File

@@ -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"
} }

View 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"
}

View 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

View 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"
]
}

View File

@@ -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"
} }

View File

@@ -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"
} }

View File

@@ -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"
} }

View File

@@ -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"
} }

View File

@@ -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"
} }

View File

@@ -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"
} }

View File

@@ -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"
} }

View File

@@ -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"
} }

View File

@@ -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"
} }

View File

@@ -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
} }

View File

@@ -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"
} }

View 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"
}

File diff suppressed because it is too large Load Diff

View 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"
]
}

View File

@@ -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

View File

@@ -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=="],

View File

@@ -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"
} }

View File

@@ -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,
}); });

View File

@@ -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": {}
}
}

View File

@@ -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": {}
}
}

View File

@@ -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
} }
] ]
} }

View File

@@ -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],
}), }),
})); }));

View File

@@ -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",
}),
],
);

View File

@@ -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;

View File

@@ -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"
}
} }

View File

@@ -1,5 +1,5 @@
const config = { const config = {
plugins: ["@tailwindcss/postcss"], plugins: ["@tailwindcss/postcss"],
}; };
export default config; export default config;

View File

@@ -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"
} }
] ]
} }

View File

@@ -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 },
); );
} }

View File

@@ -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 },

View File

@@ -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>
) );
} }

View File

@@ -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>
);
} }

View File

@@ -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>
) );
} }

View File

@@ -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>
); );
} }

View File

@@ -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>
)
} }

View File

@@ -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, {

View File

@@ -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>
</>
);
};

View File

@@ -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>
) );
} };

View File

@@ -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>
</>
);
};

View File

@@ -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>
) );
} };

View File

@@ -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>
);
};

View File

@@ -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>
) );
} };

View File

@@ -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>
</>
);
} }

View File

@@ -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>
) );
} }

View File

@@ -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>
);
} }

View File

@@ -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),
} };
} }
} }

View File

@@ -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>
) );
} }

View File

@@ -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>;
} }

View File

@@ -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 };

View File

@@ -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 };

View File

@@ -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 };

View File

@@ -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,
} };

View File

@@ -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 };

View File

@@ -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,
} };

View File

@@ -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,
} };

View File

@@ -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 };

View File

@@ -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 };

View File

@@ -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,
} };

View File

@@ -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 };

View File

@@ -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 };

View File

@@ -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 });

View File

@@ -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(),
}); });

View File

@@ -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;

View File

@@ -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);
} }

View 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,
});

View File

@@ -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;
} }

View File

@@ -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));
} }

View File

@@ -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"]
} }