Initial commit from v0

This commit is contained in:
v0
2026-03-11 04:44:48 +00:00
commit 296c5dd78d
123 changed files with 21242 additions and 0 deletions
+10
View File
@@ -0,0 +1,10 @@
# v0 runtime files
__v0_runtime_loader.js
__v0_devtools.tsx
__v0_jsx-dev-runtime.ts
# Common ignores
node_modules/
.next/
.env*.local
.DS_Store
+185
View File
@@ -0,0 +1,185 @@
# Hackerspace.sh - Full Audit & Implementation Plan
## Critical Auth/Signup Issues (BLOCKING)
### 1. Signup Flow Completely Broken ❌
**Problem**: Signup page tries to manually insert spaces/members after auth.signUp, but RLS blocks it because user isn't confirmed yet (no session, auth.uid() = null)
**Fix**: Update signup to pass ALL metadata to trigger, remove manual inserts
- Pass `space_action`, `space_name`, `space_slug`, `space_city`, `invite_code` (for create)
- Pass `space_action`, `join_invite_code` (for join)
- Remove all manual supabase.from() inserts
- Trigger (003_signup_trigger.sql) already handles everything with security definer
**Files**: `/app/signup/page.tsx`
### 2. App Layout References Non-Existent Column ❌
**Problem**: Layout queries `approved` column but schema only has `status`
**Fix**: Change `.eq('approved', true)` to `.eq('status', 'active')`
**Files**: `/app/(app)/layout.tsx`
### 3. Login/Signup UI Changed from V5 ❌
**Problem**: User says login/signup UI changed from mockup version
**Fix**: Restore exact dark theme with lime-yellow accent (#d4f53c), grid background, monospace everything
- Login: same styling as mockup screenshot
- Signup: single-step form with inline "Create/Join" toggle, not two-step flow
**Files**: `/app/login/page.tsx`, `/app/signup/page.tsx`
---
## UI/UX Issues
### 4. Dashboard Has Undefined Variables ❌
**Problem**: Dashboard references `activeMembers`, `openPayments`, `openTasks`, `tasks`, `projects`, `activity` but never queries them
**Fix**: Add proper Supabase queries for all stats and data
**Files**: `/app/(app)/dashboard/page.tsx`
### 5. Comms Incorrectly Attributes CLASP ❌
**Problem**: Comms says "powered by CLASP" but we're using Supabase Realtime, not the CLASP relay
**Fix**: Remove CLASP attribution entirely
**Files**: `/app/(app)/comms/comms-client.tsx`
### 6. App Shell Component Not Used ❌
**Problem**: AppShell component exists but layout doesn't use it
**Fix**: Update layout to use AppShell wrapper
**Files**: `/app/(app)/layout.tsx`, `/components/app-shell.tsx`
---
## Non-Functional Features (UI-Only, No Backend)
### 7. Payment Integration Buttons Do Nothing ❌
**Problem**: PayPal/Zeffy/Venmo "Connect" buttons are placeholders
**Fix**: Implement OAuth flows + API key management for each:
- **PayPal**: OAuth 2.0 → store credentials encrypted → sync transactions API
- **Zeffy**: API key input → store encrypted → webhook listener
- **Venmo**: Business API (if available) or manual CSV import fallback
**Files**: `/app/(app)/settings/settings-client.tsx`, create `/app/api/integrations/[platform]/route.ts`
### 8. Payment Reconciliation "Link Member" Buttons Do Nothing ❌
**Problem**: Clicking "+ Link member" does nothing
**Fix**: Create modal/dialog with member search/select, update payment.member_id
**Files**: `/app/(app)/payments/page.tsx`
### 9. Sync Platforms Button Does Nothing ❌
**Problem**: "Sync Platforms" button is placeholder
**Fix**: Call APIs for connected platforms, fetch recent transactions, insert/update payments table
**Files**: `/app/(app)/payments/page.tsx`
### 10. Log Cash Button Does Nothing ❌
**Problem**: "Log Cash" button is placeholder
**Fix**: Create dialog to manually log cash payment with amount, member, date, note
**Files**: `/app/(app)/payments/page.tsx`
### 11. Settings "Save Changes" Doesn't Actually Save ❌
**Problem**: Settings has client-side state but doesn't persist beyond current session
**Fix**: Verify Update queries are working, add error handling
**Files**: `/app/(app)/settings/settings-client.tsx`
### 12. Import/Sync CSV Upload Does Nothing ❌
**Problem**: CSV upload UI exists but file handling is incomplete
**Fix**: Add file upload → parse CSV → preview → map columns → validate → batch insert with error handling
**Files**: `/app/(app)/import/page.tsx`
### 13. Database Connector Doesn't Connect ❌
**Problem**: "Connect & Map Schema" button placeholder
**Fix**: Implement connection string parsing → test connection → fetch schema → generate mapping → preview import
**Files**: `/app/(app)/import/page.tsx`
### 14. Task/Chore "Claim" Buttons Don't Work ❌
**Problem**: Claim buttons are placeholders
**Fix**: Update task status to 'claimed', set claimed_by to current user, refresh data
**Files**: `/app/(app)/dashboard/page.tsx`, `/app/(app)/tasks/page.tsx`
### 15. Quick Task Button Goes Nowhere ❌
**Problem**: "+ Quick Task" button links to non-existent `/tasks/new`
**Fix**: Create task creation modal or page
**Files**: `/app/(app)/dashboard/page.tsx`, create `/app/(app)/tasks/new/page.tsx` or modal
### 16. Members Page "Add Member" Does Nothing ❌
**Problem**: "+ Add Member" button placeholder
**Fix**: Create modal to manually add member or send invite
**Files**: `/app/(app)/members/page.tsx`
### 17. Projects "New Project" Does Nothing ❌
**Problem**: "+ New Project" button placeholder
**Fix**: Create project creation modal/page
**Files**: `/app/(app)/projects/page.tsx`
### 18. Contacts "Add Contact" Does Nothing ❌
**Problem**: "+ Add Contact" button placeholder
**Fix**: Create contact creation modal/page
**Files**: `/app/(app)/contacts/page.tsx`
### 19. Ops & Facilities "Add Entry" Does Nothing ❌
**Problem**: "+ Add Entry" button placeholder
**Fix**: Create KB/secret entry creation modal/page with access control
**Files**: `/app/(app)/ops/page.tsx`
---
## Testing
### 20. No Tests Exist ❌
**Problem**: User requested tests for everything
**Fix**: Add Vitest + React Testing Library, write:
- Unit tests for all utility functions
- Integration tests for auth flows
- Component tests for critical UI
- E2E tests for happy paths (signup → dashboard → claim task → etc)
**Files**: Create `/tests/*`, add vitest config
---
## Summary Stats
- **Blocking Issues**: 3 (signup broken, layout SQL error, UI mismatch)
- **Non-Functional Features**: 17
- **Total Issues**: 20
## Implementation Priority
1. Fix signup flow (trigger-based)
2. Fix app layout SQL query
3. Restore auth page UI to match mockups
4. Add all missing queries to dashboard
5. Remove CLASP attribution
6. Implement payment platform integrations
7. Make all buttons functional
8. Write comprehensive test suite
+386
View File
@@ -0,0 +1,386 @@
# DB Schema Map — Complete Reference
Last updated: 2026-03-10. Source of truth: **LIVE DB** (queried directly via supabase_execute_sql).
---
## ENUMS — exact live values (queried 2026-03-10)
| Enum | Live Values |
|------|-------------|
| `member_role` | `admin`, `board`, `treasurer`, `member`, `associate` |
| `member_tier` | `plus`, `basic`, `associate` |
| `member_status` | `current`, `late`, `inactive`, `unverified` — **NOT** `active` or `pending` |
| `task_type` | `chore`, `task` — column is named **`type`**, NOT `task_type`. NO `ongoing` |
| `task_status` | `open`, `claimed`, `in_progress`, `overdue`, `due_today`, `completed`, `done`, `blocked` |
| `recurrence_type` | `daily`, `weekly`, `biweekly`, `monthly`, `none` — NO `quarterly` |
| `project_status` | `backlog`, `in_progress`, `review`, `done`, `blocked` |
| `kb_visibility` | `all_members`, `board`, `admin_only` — **NOT** `admins_only` |
| `contact_type` | `vendor`, `supplier`, `partner`, `landlord`, `city` — NO `contractor` |
| `area_lead_status` | `active`, `vacant`, `handoff` |
| `payment_platform` | `paypal`, `zeffy`, `venmo`, `cash` |
| `payment_link_status` | `linked`, `unlinked` |
| `channel_type` | `general`, `area`, `ops`, `project` — NO `board` |
| `contact_type` | `vendor`, `supplier`, `partner`, `landlord`, `city` |
| `payment_platform` | `paypal`, `zeffy`, `venmo`, `cash` |
| `payment_link_status` | `linked`, `unlinked` |
| `channel_type` | `general`, `area`, `ops`, `project` |
---
## TABLES — Every column, exact names
### `spaces`
| Column | Type | Notes |
|--------|------|-------|
| `id` | uuid PK | |
| `name` | text NOT NULL | |
| `slug` | text UNIQUE NOT NULL | |
| `city` | text | nullable |
| `require_approval` | boolean | default true |
| `public_member_directory` | boolean | default false |
| `invite_code` | text UNIQUE NOT NULL | |
| `webhook_secret` | text | nullable |
| `created_at` | timestamptz | |
| `updated_at` | timestamptz | |
**Does NOT have:** `description`, `logo`, `timezone`
---
### `space_members`
| Column | Type | Notes |
|--------|------|-------|
| `id` | uuid PK | |
| `space_id` | uuid FK → spaces | NOT NULL |
| `user_id` | uuid FK → auth.users | **NULLABLE** — offline members have no user_id |
| `role` | text | default `'member'` |
| `tier` | text | default `'basic'` |
| `display_name` | text NOT NULL | |
| `email` | text | nullable |
| `handle` | text | nullable |
| `phone` | text | nullable |
| `status` | text | default `'active'` |
| `payment_status` | text | nullable |
| `payment_note` | text | nullable |
| `joined_at` | timestamptz | |
| `last_paid_at` | timestamptz | nullable — **NOT** `last_payment_at` |
| `has_card_access` | boolean | default false |
| `approved` | boolean | default **true** (fixed script 008) |
| UNIQUE | `(space_id, user_id)` | |
| UNIQUE | `(space_id, email)` | added script 009 |
**Does NOT have:** `last_payment_at`, `approved_at`, `bio`, `avatar_url`
---
### `tasks`
| Column | Type | Notes |
|--------|------|-------|
| `id` | uuid PK | |
| `space_id` | uuid FK → spaces | NOT NULL |
| `title` | text NOT NULL | |
| `description` | text | nullable |
| `type` | task_type | default `'task'` — column is **`type`**, NOT `task_type` |
| `status` | task_status | default `'open'` — use `'completed'` not `'done'` |
| `area` | text | nullable |
| `recurrence` | recurrence_type | default `'none'` |
| `due_date` | timestamptz | nullable |
| `assigned_to` | uuid FK → auth.users | nullable |
| `assigned_to_name` | text | nullable |
| `claimed_by` | uuid FK → auth.users | nullable |
| `claimed_by_name` | text | nullable |
| `requested_by` | uuid FK → auth.users | nullable |
| `requested_by_name` | text | nullable |
| `subtask_total` | integer | default 0 |
| `subtask_completed` | integer | default 0 |
| `last_done_at` | timestamptz | nullable |
| `completed_at` | timestamptz | nullable |
| `created_at` | timestamptz | |
| `updated_at` | timestamptz | |
**Does NOT have:** `task_type`, `progress`, `blocked`, `priority`
---
### `projects`
| Column | Type | Notes |
|--------|------|-------|
| `id` | uuid PK | |
| `space_id` | uuid FK → spaces | NOT NULL |
| `title` | text NOT NULL | |
| `description` | text | nullable |
| `status` | project_status | default `'backlog'` |
| `area` | text | nullable |
| `tags` | text[] | nullable |
| `assignee_names` | text[] | nullable |
| `task_count` | integer | default 0 |
| `tasks_completed` | integer | default 0 |
| `due_date` | timestamptz | nullable |
| `created_at` | timestamptz | |
| `updated_at` | timestamptz | |
**Does NOT have:** `progress`, `blocked`, `priority`, `owner_id`
---
### `knowledge_base`
| Column | Type | Notes |
|--------|------|-------|
| `id` | uuid PK | |
| `space_id` | uuid FK → spaces | NOT NULL |
| `title` | text NOT NULL | |
| `content` | text | nullable |
| `icon` | text | nullable |
| `visibility` | kb_visibility | default `'all_members'` |
| `area` | text | nullable |
| `tags` | text[] | nullable |
| `is_pinned` | boolean | default false — column is **`is_pinned`**, NOT `pinned` |
| `updated_by_id` | uuid FK → auth.users | nullable |
| `updated_by_name` | text | nullable — **NOT** `updated_by` |
| `created_at` | timestamptz | |
| `updated_at` | timestamptz | |
**Does NOT have:** `description`, `pinned`, `updated_by`, `summary`
---
### `secrets`
| Column | Type | Notes |
|--------|------|-------|
| `id` | uuid PK | |
| `space_id` | uuid FK → spaces | NOT NULL |
| `title` | text NOT NULL | |
| `icon` | text | nullable |
| `description` | text | nullable |
| `value` | text NOT NULL | |
| `area` | text | nullable |
| `created_at` | timestamptz | |
| `updated_at` | timestamptz | |
---
### `area_leads`
| Column | Type | Notes |
|--------|------|-------|
| `id` | uuid PK | |
| `space_id` | uuid FK → spaces | NOT NULL |
| `area_name` | text NOT NULL | |
| `area_code` | text NOT NULL | |
| `lead_id` | uuid FK → auth.users | nullable |
| `lead_handle` | text | nullable |
| `status` | area_lead_status | default `'active'` |
| `created_at` | timestamptz | |
| `updated_at` | timestamptz | |
| UNIQUE | `(space_id, area_code)` | added script 007 |
---
### `contacts`
| Column | Type | Notes |
|--------|------|-------|
| `id` | uuid PK | |
| `space_id` | uuid FK → spaces | NOT NULL |
| `name` | text NOT NULL | |
| `code` | text NOT NULL | auto-generated 3-char prefix + random 3 digits |
| `contact_type` | contact_type NOT NULL | |
| `email` | text | nullable |
| `phone` | text | nullable |
| `details` | text | nullable |
| `note` | text | nullable — added script 007 |
| `group_label` | text | nullable — added script 007 |
| `tags` | text[] | nullable |
| `created_at` | timestamptz | |
| `updated_at` | timestamptz | |
---
### `payments`
| Column | Type | Notes |
|--------|------|-------|
| `id` | uuid PK | |
| `space_id` | uuid FK → spaces | NOT NULL |
| `platform` | payment_platform NOT NULL | |
| `amount` | numeric(10,2) NOT NULL | |
| `from_identifier` | text | nullable |
| `from_note` | text | nullable |
| `member_id` | uuid FK → space_members | nullable |
| `member_name` | text | nullable |
| `link_status` | payment_link_status | default `'unlinked'` |
| `transaction_date` | timestamptz NOT NULL | |
| `created_at` | timestamptz | |
**Does NOT have:** `description`, `reference`, `currency`
---
### `comms_channels`
| Column | Type | Notes |
|--------|------|-------|
| `id` | uuid PK | |
| `space_id` | uuid FK → spaces | NOT NULL |
| `name` | text NOT NULL | |
| `icon` | text | nullable |
| `channel_type` | channel_type | default `'general'` |
| `area_reference` | text | nullable |
| `project_id` | uuid FK → projects | nullable, cascade delete |
| `member_count` | integer | default 0 |
| `created_at` | timestamptz | |
**Does NOT have:** `description`, `topic`, `unread_count`, `is_archived`
Default channels created by trigger on space INSERT: `general`, `announcements`, `random`, `facilities`
---
### `comms_messages`
| Column | Type | Notes |
|--------|------|-------|
| `id` | uuid PK | |
| `channel_id` | uuid FK → comms_channels | NOT NULL, cascade delete |
| `space_id` | uuid FK → spaces | NOT NULL |
| `user_id` | uuid FK → auth.users | nullable — **NOT** `sender_id` |
| `display_name` | text NOT NULL | |
| `handle` | text | nullable |
| `content` | text NOT NULL | **NOT** `message`, **NOT** `body` |
| `created_at` | timestamptz | |
**Does NOT have:** `message`, `body`, `sender_id`, `edited_at`, `reactions`
---
### `integrations`
| Column | Type | Notes |
|--------|------|-------|
| `id` | uuid PK | |
| `space_id` | uuid FK → spaces | NOT NULL |
| `name` | text NOT NULL | |
| `platform` | text NOT NULL | |
| `description` | text | nullable |
| `is_connected` | boolean | default false |
| `config` | jsonb | nullable |
| `created_at` | timestamptz | |
| `updated_at` | timestamptz | |
| UNIQUE | `(space_id, platform)` | added script 007 |
---
### `activity_log`
| Column | Type | Notes |
|--------|------|-------|
| `id` | uuid PK | |
| `space_id` | uuid FK → spaces | NOT NULL |
| `user_id` | uuid FK → auth.users | nullable |
| `display_name` | text | nullable |
| `action` | text NOT NULL | |
| `entity_type` | text | nullable |
| `entity_id` | uuid | nullable |
| `details` | text | nullable |
| `created_at` | timestamptz | |
---
## USER FLOWS
### Sign Up → Create Space
1. `/signup` form → `signUp()` → Supabase auth.signUp
2. After email confirm / immediate: `createSpace({ spaceName, spaceSlug, displayName })` (admin client, bypasses RLS)
3. Inserts `spaces` → trigger fires → inserts 4 default `comms_channels`
4. Inserts `space_members` as role=`admin`, tier=`plus`, status=`active`, approved=`true`
5. Redirect → `/dashboard`
### Sign Up → Join Space
1. `/signup` form → `signUp()`
2. `joinSpace({ inviteCode, displayName })` (admin client)
3. Looks up `spaces.invite_code` → inserts `space_members` with role=`member`, status=`active` (or `pending` if `require_approval=true`), approved=`true`
4. Redirect → `/dashboard`
### Login
1. `/login` → `signIn(email, password)` → Supabase auth
2. Middleware checks session cookie
3. App layout RSC calls `getCurrentMembership()` → queries `space_members` WHERE `user_id = auth.uid()` AND `status IN ('active', 'pending')`
4. If no membership found → redirect `/signup`
### Dashboard
- RSC queries: tasks (open/in_progress), projects (in_progress), recent payments, activity_log, all scoped to `space_id`
### Tasks
- List: `tasks` WHERE `space_id = ?` ORDER BY `created_at DESC`
- Create: `createTask()` → status=`open`, type=`task|chore`
- Claim: `claimTask()` → status=`claimed`
- Complete: `completeTask()` → status=`completed` (NOT `done`)
- Delete: `deleteTask()`
### Projects
- Kanban columns: `backlog` | `in_progress` | `review` | `done`
- Create: `createProject()` → status=`backlog`
- Move: `updateProjectStatus(id, status)` where status ∈ project_status enum
### Members (admin/board write)
- List: all `space_members` for space
- Add: `addMember()` (RLS: user_has_role_in_space admin/board)
- Edit: `updateMember()`
- Approve: `approveMember()` → status=`active`, approved=`true`
- Remove: `removeMember()` (admin only)
- CSV: `importMembers()` → upsert on `(space_id, email)`
### Payments (treasurer/admin/board)
- List: `payments` JOIN `space_members` for display_name
- Log cash: `logCashPayment()` → platform=`cash`
- Link: `linkPaymentToMember()` → updates `member_id`, `link_status=linked`, member `last_paid_at`
- CSV: `importPaymentsCsv()`
### Ops / Knowledge Base
- List: `knowledge_base` ordered by `is_pinned DESC`, `created_at DESC`
- Create: `createKbEntry()` → columns: title, content, area, visibility, is_pinned, tags, icon, updated_by_id, updated_by_name
- Update: `updateKbEntry()` → same columns
- Secrets: `createSecret()`, `deleteSecret()` — admin/board only via RLS
- Area leads: `upsertAreaLead()` on conflict `(space_id, area_code)`
### Comms
- Channels: RSC fetches `comms_channels` WHERE `space_id = ?`
- Messages: client fetches `comms_messages` WHERE `channel_id = ?` ORDER BY `created_at`
- Realtime: Supabase channel subscription on `comms_messages` for `channel_id=eq.{id}`
- Send: INSERT `comms_messages` (channel_id, space_id, **user_id**, display_name, handle, **content**)
### Settings (admin)
- Space: `updateSpaceSettings()`
- Integrations: `saveIntegration()` upsert on `(space_id, platform)`, `disconnectIntegration()`
- Webhook: `rotateWebhookSecret()`
---
## RLS SUMMARY
| Table | SELECT | INSERT | UPDATE | DELETE |
|-------|--------|--------|--------|--------|
| `spaces` | own space members | admin client only (bootstrap) | admin | — |
| `space_members` | own space | self OR admin/board | self OR admin | admin |
| `tasks` | members | members | members | members |
| `projects` | members | members | members | members |
| `knowledge_base` | members (visibility-scoped) | members | members | board/admin |
| `secrets` | admin/board/treasurer | admin | admin | admin |
| `area_leads` | members | board/admin | board/admin | board/admin |
| `contacts` | members | members | members | members |
| `payments` | treasurer/board/admin | treasurer/admin | treasurer/admin | treasurer/admin |
| `comms_channels` | members | board/admin | board/admin | board/admin |
| `comms_messages` | members | members (user_id=auth.uid()) | — | — |
| `integrations` | admin | admin | admin | admin |
| `activity_log` | members | members | — | — |
---
## MIGRATIONS APPLIED
| Script | Change |
|--------|--------|
| 001 | Full schema creation |
| 002 | Schema fixes + create_default_channels trigger (original — had bug) |
| 003 | Signup trigger |
| 004 | Admin helper functions |
| 005 | Additional fixes |
| 006 | Comprehensive RLS rewrite |
| 007 | Added task_status `done`; contacts `note`/`group_label`; unique on integrations(space_id,platform) and area_leads(space_id,area_code) |
| 008 | space_members.approved default=true; backfilled NULLs — fixed login loop |
| 009 | space_members INSERT RLS: allow admin/board inserts; added UNIQUE(space_id,email) |
| 010 | Fixed create_default_channels trigger — removed non-existent `description` column |
+137
View File
@@ -0,0 +1,137 @@
# Hackerspace.sh Implementation Status
## Critical Issues to Fix
### 1. **Auth/Signup Flow** ❌ BROKEN
- Problem: Signup tries to insert records directly but RLS blocks it
- Solution: Use trigger-based signup (already created in 003_signup_trigger.sql)
- Need to: Simplify signup to just create auth user with metadata, let trigger handle space/member creation
### 2. **CLASP Attribution** ❌ REMOVE
- Currently shows "powered by CLASP" but we're using Supabase Realtime
- Remove CLASP mentions from comms UI
### 3. **Payment Integrations** ❌ NOT IMPLEMENTED
- PayPal, Zeffy, Venmo connections show as UI only
- Need: OAuth flows or API key management
- Need: Webhook handlers for transaction sync
- Need: Manual transaction import
### 4. **Import/Export** ❌ UI ONLY
- CSV import shows column mapping UI but doesn't actually import
- Database connector shows form but doesn't connect
- Need: Actual file parsing and database inserts
### 5. **Settings Actions** ❌ UI ONLY
- Integration "Connect" buttons do nothing
- Webhook endpoint management doesn't work
- Need: OAuth flows, API key storage, webhook secret management
### 6. **Member Management** ❌ INCOMPLETE
- Add member button doesn't work
- Approve/deny pending members not functional
- Edit member details not working
### 7. **Task/Project CRUD** ❌ UI ONLY
- Cannot create new tasks/chores
- Cannot update task status
- Cannot create/update projects
- Kanban drag-and-drop not functional
### 8. **Ops & KB** ❌ UI ONLY
- Cannot add knowledge base entries
- Cannot add secrets
- Area lead assignment doesn't work
### 9. **Contacts CRUD** ❌ UI ONLY
- Add contact button doesn't work
- Cannot edit/delete contacts
### 10. **Dashboard Stats** ❌ FAKE DATA
- All numbers are hardcoded
- Need: Real database queries
## What Works ✅
- Database schema (13 tables, all RLS policies)
- Auth infrastructure (Supabase client/server/proxy)
- Comms real-time messaging (Supabase Realtime)
- UI/UX design matches mockups exactly
- Dark theme with correct colors
- Sidebar navigation
## Implementation Plan
### Phase 1: Fix Auth (PRIORITY 1)
1. Remove direct database inserts from signup
2. Rely on trigger for space/member creation
3. Handle "join via invite" properly
4. Add proper error handling and loading states
### Phase 2: Make All CRUD Functional
1. Tasks: Create, update, claim, complete
2. Projects: Create, update, change status
3. Members: Add, edit, approve/deny, remove
4. Contacts: Full CRUD
5. KB: Add, edit, delete entries
6. Secrets: Add, edit, delete (admins only)
### Phase 3: Payment Integration
1. Build OAuth flow for PayPal/Venmo
2. API key storage for Zeffy
3. Webhook handlers for transaction sync
4. Manual transaction logging
5. Auto-link transactions to members
### Phase 4: Import/Export
1. CSV parser for member data
2. Database connector (PostgreSQL, MySQL)
3. Export functionality
### Phase 5: Settings & Admin
1. Integration OAuth flows
2. Webhook secret rotation
3. Space settings updates
4. Role/permission management
### Phase 6: Testing
1. Unit tests for all server actions
2. Integration tests for auth flow
3. E2E tests for critical paths
4. Payment webhook testing
## Testing Strategy
### Unit Tests (Vitest)
- Server actions (auth, CRUD)
- Utility functions
- Form validation
### Integration Tests
- Auth flows
- Database operations with RLS
- Webhook handlers
### E2E Tests (Playwright)
- Full signup flow
- Task creation and claiming
- Project management
- Member management
- Payment reconciliation
## API Documentation Needed
### PayPal
- OAuth 2.0 for transaction access
- Webhook events for payments
- REST API for transaction history
### Zeffy
- API key authentication
- Transaction sync endpoint
- Webhook for donations
### Venmo
- Business API (if available)
- OAuth flow
- Transaction webhooks
+246
View File
@@ -0,0 +1,246 @@
'use client'
import { useState, useEffect, useRef } from 'react'
import { createClient } from '@/lib/supabase/client'
import { Hash, Users2 } from 'lucide-react'
interface Props {
member: any
space: any
channels: any[]
}
export default function CommsClient({ member, space, channels }: Props) {
const [selectedChannel, setSelectedChannel] = useState(channels[0] || null)
const [messages, setMessages] = useState<any[]>([])
const [newMessage, setNewMessage] = useState('')
const [sending, setSending] = useState(false)
const messagesEndRef = useRef<HTMLDivElement>(null)
useEffect(() => {
if (!selectedChannel) return
loadMessages()
const supabase = createClient()
const subscription = supabase
.channel(`channel:${selectedChannel.id}`)
.on('postgres_changes', { event: '*', schema: 'public', table: 'comms_messages', filter: `channel_id=eq.${selectedChannel.id}` }, payload => {
if (payload.eventType === 'INSERT') {
setMessages(prev => [...prev, payload.new])
}
})
.subscribe()
return () => { subscription.unsubscribe() }
}, [selectedChannel])
useEffect(() => {
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' })
}, [messages])
async function loadMessages() {
const supabase = createClient()
const { data } = await supabase
.from('comms_messages')
.select('*')
.eq('channel_id', selectedChannel.id)
.order('created_at')
.limit(100)
setMessages(data ?? [])
}
async function sendMessage(e: React.FormEvent) {
e.preventDefault()
if (!newMessage.trim() || sending) return
setSending(true)
const supabase = createClient()
await supabase.from('comms_messages').insert({
channel_id: selectedChannel.id,
space_id: space?.id ?? member.space_id,
user_id: member.user_id,
display_name: member.display_name,
handle: member.handle,
content: newMessage.trim(),
})
setNewMessage('')
setSending(false)
}
const generalChannels = channels.filter(c => c.channel_type === 'general')
const areaChannels = channels.filter(c => c.channel_type === 'area')
const projectChannels = channels.filter(c => c.channel_type === 'project')
return (
<div className="h-screen bg-background flex flex-col">
<div className="bg-sidebar px-6 py-3 flex items-center justify-between border-b border-sidebar-border">
<div className="flex items-center gap-3">
<h1 className="text-white font-sans text-lg font-semibold">Comms</h1>
</div>
<div className="flex items-center gap-2">
<button className="text-sidebar-foreground/70 hover:text-sidebar-foreground text-sm">
<Users2 className="w-4 h-4" />
</button>
<button className="text-sidebar-foreground/70 hover:text-sidebar-foreground text-sm">
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
</svg>
</button>
</div>
</div>
<div className="flex-1 flex overflow-hidden">
{/* Channels sidebar */}
<aside className="w-[220px] bg-card border-r border-border overflow-y-auto">
<div className="p-3">
{generalChannels.length > 0 && (
<div className="mb-4">
<p className="font-mono text-[10px] tracking-widest text-muted-foreground uppercase mb-2 px-2">GENERAL</p>
{generalChannels.map(channel => (
<button
key={channel.id}
onClick={() => setSelectedChannel(channel)}
className={`w-full flex items-center gap-2 px-2 py-1.5 rounded text-sm font-sans transition ${
selectedChannel?.id === channel.id
? 'bg-primary/10 text-primary'
: 'text-foreground/70 hover:bg-muted hover:text-foreground'
}`}
>
<Hash className="w-3.5 h-3.5 flex-shrink-0" />
<span className="truncate">{channel.name}</span>
{channel.unread_count > 0 && (
<span className="ml-auto w-4 h-4 bg-primary text-white text-[9px] font-bold rounded-full flex items-center justify-center">
{channel.unread_count}
</span>
)}
</button>
))}
</div>
)}
{areaChannels.length > 0 && (
<div className="mb-4">
<p className="font-mono text-[10px] tracking-widest text-muted-foreground uppercase mb-2 px-2">AREAS</p>
{areaChannels.map(channel => (
<button
key={channel.id}
onClick={() => setSelectedChannel(channel)}
className={`w-full flex items-center gap-2 px-2 py-1.5 rounded text-sm font-sans transition ${
selectedChannel?.id === channel.id
? 'bg-primary/10 text-primary'
: 'text-foreground/70 hover:bg-muted hover:text-foreground'
}`}
>
<Hash className="w-3.5 h-3.5 flex-shrink-0" />
<span className="truncate">{channel.name}</span>
</button>
))}
</div>
)}
{projectChannels.length > 0 && (
<div>
<p className="font-mono text-[10px] tracking-widest text-muted-foreground uppercase mb-2 px-2">PROJECTS</p>
{projectChannels.map(channel => (
<button
key={channel.id}
onClick={() => setSelectedChannel(channel)}
className={`w-full flex items-center gap-2 px-2 py-1.5 rounded text-sm font-sans transition ${
selectedChannel?.id === channel.id
? 'bg-primary/10 text-primary'
: 'text-foreground/70 hover:bg-muted hover:text-foreground'
}`}
>
<svg className="w-3.5 h-3.5 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 10V3L4 14h7v7l9-11h-7z" />
</svg>
<span className="truncate">{channel.name}</span>
</button>
))}
</div>
)}
</div>
</aside>
{/* Messages area */}
{selectedChannel ? (
<div className="flex-1 flex flex-col">
{/* Channel header */}
<div className="px-4 py-3 border-b border-border flex items-center gap-2">
<Hash className="w-5 h-5 text-primary" />
<div>
<p className="font-sans text-sm font-medium text-foreground">{selectedChannel.name}</p>
{selectedChannel.description && (
<p className="font-sans text-xs text-muted-foreground">{selectedChannel.description}</p>
)}
</div>
{selectedChannel.member_count && (
<span className="ml-auto font-mono text-[10px] text-muted-foreground">
{selectedChannel.member_count} members
</span>
)}
</div>
{/* Messages */}
<div className="flex-1 overflow-y-auto p-4 space-y-4">
{messages.map((msg, i) => {
const showAvatar = i === 0 || messages[i - 1]?.user_id !== msg.user_id
return (
<div key={msg.id} className={`flex items-start gap-3 ${showAvatar ? '' : 'pl-[52px]'}`}>
{showAvatar && (
<div className="w-8 h-8 rounded bg-primary/10 flex items-center justify-center text-[10px] font-mono font-bold text-primary flex-shrink-0">
{(msg.display_name || 'U').split(' ').map((n: string) => n[0]).join('').toUpperCase().slice(0, 2)}
</div>
)}
<div className="flex-1 min-w-0">
{showAvatar && (
<div className="flex items-baseline gap-2 mb-0.5">
<span className="font-sans text-sm font-medium text-foreground">{msg.display_name || 'Unknown'}</span>
{msg.handle && <span className="font-mono text-[10px] text-muted-foreground">@{msg.handle}</span>}
<span className="font-mono text-[10px] text-muted-foreground">
{new Date(msg.created_at).toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit' })}
</span>
</div>
)}
<p className="font-sans text-sm text-foreground leading-relaxed break-words">
{(msg.content || '').split(/(@\w+|#\w+)/g).map((part: string, j: number) => {
if (part.startsWith('@')) return <span key={j} className="text-primary font-medium">{part}</span>
if (part.startsWith('#')) return <span key={j} className="text-blue-600 font-medium">{part}</span>
return part
})}
</p>
</div>
</div>
)
})}
<div ref={messagesEndRef} />
</div>
{/* Input */}
<div className="p-4 border-t border-border">
<form onSubmit={sendMessage} className="flex gap-2">
<input
type="text"
value={newMessage}
onChange={e => setNewMessage(e.target.value)}
placeholder={`Message #${selectedChannel.name}`}
className="flex-1 bg-muted border border-border rounded px-3 py-2 font-sans text-sm text-foreground placeholder:text-muted-foreground focus:outline-none focus:border-primary transition"
/>
<button
type="submit"
disabled={!newMessage.trim() || sending}
className="bg-primary text-white px-4 py-2 rounded font-sans text-sm hover:bg-primary/90 transition disabled:opacity-40"
>
Send
</button>
</form>
</div>
</div>
) : (
<div className="flex-1 flex items-center justify-center text-muted-foreground">
<p className="font-sans text-sm">Select a channel to view messages</p>
</div>
)}
</div>
</div>
)
}
+21
View File
@@ -0,0 +1,21 @@
import { createClient } from '@/lib/supabase/server'
import CommsClient from './comms-client'
export default async function CommsPage() {
const supabase = await createClient()
const { data: { user } } = await supabase.auth.getUser()
if (!user) return null
const { data: member } = await supabase
.from('space_members').select('*, spaces(id, name, slug)').eq('user_id', user.id).eq('status', 'current').single()
if (!member) return null
const { data: channels } = await supabase
.from('comms_channels')
.select('*')
.eq('space_id', member!.space_id)
.order('channel_type')
.order('name')
return <CommsClient member={member} space={member?.spaces} channels={channels ?? []} />
}
+284
View File
@@ -0,0 +1,284 @@
'use client'
import { useState } from 'react'
import { Plus, X, Pencil } from 'lucide-react'
import { createContact, updateContact, deleteContact } from '@/lib/actions'
interface Contact {
id: string
name: string
contact_type: string
email?: string
phone?: string
details?: string
note?: string
group_label?: string
tags?: string[]
code?: string
}
const CATEGORY_COLORS: Record<string, string> = {
vendor: 'text-primary border-primary/30 bg-primary/5',
supplier: 'text-muted-foreground border-border bg-muted',
landlord: 'text-orange-600 border-orange-300 bg-orange-50',
city: 'text-red-600 border-red-300 bg-red-50',
partner: 'text-blue-600 border-blue-300 bg-blue-50',
}
const GROUPS: Record<string, string[]> = {
'Vendors & Suppliers': ['vendor', 'supplier'],
'Landlord & City': ['landlord', 'city'],
'Community & Partners': ['partner'],
}
export function ContactsClient({ contacts: initialContacts }: { contacts: Contact[] }) {
const [contacts, setContacts] = useState<Contact[]>(initialContacts)
const [search, setSearch] = useState('')
const [showCreate, setShowCreate] = useState(false)
const [editContact, setEditContact] = useState<Contact | null>(null)
const [form, setForm] = useState({
name: '', contact_type: 'vendor', email: '', phone: '', details: '', group_label: '', tags: ''
})
const [loading, setLoading] = useState(false)
const [error, setError] = useState('')
const filtered = search
? contacts.filter(c =>
c.name.toLowerCase().includes(search.toLowerCase()) ||
c.email?.toLowerCase().includes(search.toLowerCase()) ||
c.phone?.includes(search)
)
: contacts
// Group by type
const grouped: Record<string, Contact[]> = {}
for (const [groupName, types] of Object.entries(GROUPS)) {
const items = filtered.filter(c => types.includes(c.contact_type))
if (items.length > 0) grouped[groupName] = items
}
// Catch any ungrouped
const knownTypes = Object.values(GROUPS).flat()
const other = filtered.filter(c => !knownTypes.includes(c.contact_type))
if (other.length > 0) grouped['Other'] = other
async function handleCreate(e: React.FormEvent) {
e.preventDefault()
setLoading(true)
setError('')
const result = await createContact({
...form,
tags: form.tags ? form.tags.split(',').map(t => t.trim()).filter(Boolean) : [],
})
if (result.error) { setError(result.error); setLoading(false); return }
if (result.data) setContacts(prev => [...prev, result.data as Contact])
setShowCreate(false)
resetForm()
setLoading(false)
}
async function handleEdit(e: React.FormEvent) {
e.preventDefault()
if (!editContact) return
setLoading(true)
setError('')
const result = await updateContact(editContact.id, {
...form,
tags: form.tags ? form.tags.split(',').map(t => t.trim()).filter(Boolean) : [],
})
if (result.error) { setError(result.error); setLoading(false); return }
setContacts(prev => prev.map(c => c.id === editContact.id ? { ...c, ...form } : c))
setEditContact(null)
setLoading(false)
}
async function handleDelete(id: string) {
if (!confirm('Delete this contact?')) return
const result = await deleteContact(id)
if (!result.error) setContacts(prev => prev.filter(c => c.id !== id))
}
function openEdit(c: Contact) {
setEditContact(c)
setForm({
name: c.name, contact_type: c.contact_type, email: c.email ?? '',
phone: c.phone ?? '', details: c.details ?? '', group_label: c.group_label ?? '',
tags: c.tags?.join(', ') ?? '',
})
setError('')
}
function resetForm() {
setForm({ name: '', contact_type: 'vendor', email: '', phone: '', details: '', group_label: '', tags: '' })
setError('')
}
const ContactForm = ({ onSubmit, submitLabel }: { onSubmit: (e: React.FormEvent) => void, submitLabel: string }) => (
<form onSubmit={onSubmit} className="p-6 space-y-4">
<div className="grid grid-cols-2 gap-4">
<div>
<label className="font-mono text-[10px] tracking-widest text-muted-foreground uppercase block mb-1">Name *</label>
<input type="text" required value={form.name} onChange={e => setForm(f => ({ ...f, name: e.target.value }))}
className="w-full bg-background border border-border rounded px-3 py-2 font-sans text-sm text-foreground focus:outline-none focus:border-primary transition" />
</div>
<div>
<label className="font-mono text-[10px] tracking-widest text-muted-foreground uppercase block mb-1">Type</label>
<select value={form.contact_type} onChange={e => setForm(f => ({ ...f, contact_type: e.target.value }))}
className="w-full bg-background border border-border rounded px-3 py-2 font-sans text-sm text-foreground focus:outline-none focus:border-primary">
{Object.keys(CATEGORY_COLORS).map(t => <option key={t}>{t}</option>)}
</select>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="font-mono text-[10px] tracking-widest text-muted-foreground uppercase block mb-1">Email</label>
<input type="email" value={form.email} onChange={e => setForm(f => ({ ...f, email: e.target.value }))}
className="w-full bg-background border border-border rounded px-3 py-2 font-sans text-sm text-foreground focus:outline-none focus:border-primary transition" />
</div>
<div>
<label className="font-mono text-[10px] tracking-widest text-muted-foreground uppercase block mb-1">Phone</label>
<input type="tel" value={form.phone} onChange={e => setForm(f => ({ ...f, phone: e.target.value }))}
className="w-full bg-background border border-border rounded px-3 py-2 font-sans text-sm text-foreground focus:outline-none focus:border-primary transition" />
</div>
</div>
<div>
<label className="font-mono text-[10px] tracking-widest text-muted-foreground uppercase block mb-1">Details / Notes</label>
<textarea value={form.details} onChange={e => setForm(f => ({ ...f, details: e.target.value }))}
rows={2} className="w-full bg-background border border-border rounded px-3 py-2 font-sans text-sm text-foreground placeholder:text-muted-foreground focus:outline-none focus:border-primary transition resize-none" />
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="font-mono text-[10px] tracking-widest text-muted-foreground uppercase block mb-1">Group Label</label>
<input type="text" value={form.group_label} onChange={e => setForm(f => ({ ...f, group_label: e.target.value }))}
placeholder="e.g. Equipment Vendors"
className="w-full bg-background border border-border rounded px-3 py-2 font-sans text-sm text-foreground focus:outline-none focus:border-primary transition" />
</div>
<div>
<label className="font-mono text-[10px] tracking-widest text-muted-foreground uppercase block mb-1">Tags</label>
<input type="text" value={form.tags} onChange={e => setForm(f => ({ ...f, tags: e.target.value }))}
placeholder="laser, cnc, maintenance"
className="w-full bg-background border border-border rounded px-3 py-2 font-mono text-xs text-foreground focus:outline-none focus:border-primary transition" />
</div>
</div>
{error && <p className="font-mono text-xs text-red-500">{error}</p>}
<div className="flex gap-3 pt-2">
<button type="button" onClick={() => { setShowCreate(false); setEditContact(null); resetForm() }}
className="flex-1 border border-border text-foreground font-sans text-sm py-2 rounded hover:border-primary/50 transition">Cancel</button>
<button type="submit" disabled={loading}
className="flex-1 bg-primary text-white font-sans text-sm py-2 rounded hover:bg-primary/90 transition disabled:opacity-60">
{loading ? 'Saving...' : submitLabel}
</button>
</div>
</form>
)
return (
<div className="min-h-screen bg-background">
<div className="bg-sidebar px-6 py-3 flex items-center justify-between">
<h1 className="text-white font-sans text-lg font-semibold">Contacts</h1>
<button
onClick={() => { resetForm(); setShowCreate(true) }}
className="flex items-center gap-1.5 bg-primary text-white text-xs font-sans px-3 py-1.5 rounded hover:bg-primary/90 transition"
>
<Plus className="w-3.5 h-3.5" /> Add Contact
</button>
</div>
<div className="p-6 space-y-4">
<div className="relative">
<svg className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
</svg>
<input
type="text"
placeholder="Search contacts..."
value={search}
onChange={e => setSearch(e.target.value)}
className="w-full bg-card border border-border rounded pl-9 pr-4 py-2.5 font-sans text-sm text-foreground placeholder:text-muted-foreground focus:outline-none focus:border-primary transition"
/>
</div>
{Object.keys(grouped).length > 0 ? (
<div className="grid md:grid-cols-2 gap-6">
{Object.entries(grouped).map(([group, items]) => (
<div key={group}>
<p className="font-mono text-[10px] tracking-widest text-muted-foreground uppercase mb-3">
{group.toUpperCase()}
</p>
<div className="bg-card rounded border border-border divide-y divide-border">
{items.map(contact => {
const initials = contact.name.split(' ').map((n: string) => n[0]).join('').toUpperCase().slice(0, 3)
return (
<div key={contact.id} className="flex items-center gap-3 px-4 py-3 group">
<div className="w-10 h-10 rounded bg-muted flex items-center justify-center text-[10px] font-mono font-bold text-muted-foreground flex-shrink-0">
{initials}
</div>
<div className="flex-1 min-w-0">
<p className="font-sans text-sm font-medium text-foreground">{contact.name}</p>
<p className="font-mono text-[10px] text-muted-foreground">
{[contact.email, contact.phone, contact.details].filter(Boolean).join(' · ')}
</p>
</div>
<span className={`font-mono text-[10px] px-2 py-0.5 rounded border flex-shrink-0 ${
CATEGORY_COLORS[contact.contact_type] ?? 'text-muted-foreground border-border bg-muted'
}`}>
{contact.contact_type?.toUpperCase()}
</span>
<div className="flex gap-1 opacity-0 group-hover:opacity-100 transition">
<button
onClick={() => openEdit(contact)}
className="text-muted-foreground hover:text-primary transition"
aria-label="Edit contact"
>
<Pencil className="w-3.5 h-3.5" />
</button>
<button
onClick={() => handleDelete(contact.id)}
className="text-muted-foreground hover:text-destructive transition"
aria-label="Delete contact"
>
<X className="w-3.5 h-3.5" />
</button>
</div>
</div>
)
})}
</div>
</div>
))}
</div>
) : (
<div className="bg-card rounded border border-dashed border-border p-16 text-center">
<p className="font-sans text-sm text-muted-foreground">No contacts yet</p>
<button onClick={() => setShowCreate(true)} className="font-mono text-xs text-primary mt-2 block mx-auto hover:underline">
+ Add first contact
</button>
</div>
)}
</div>
{showCreate && (
<div className="fixed inset-0 bg-black/60 flex items-center justify-center z-50 p-4">
<div className="bg-card border border-border rounded-lg w-full max-w-lg">
<div className="flex items-center justify-between px-6 py-4 border-b border-border">
<h2 className="font-sans text-base font-semibold text-foreground">Add Contact</h2>
<button onClick={() => { setShowCreate(false); resetForm() }} className="text-muted-foreground hover:text-foreground"><X className="w-4 h-4" /></button>
</div>
<ContactForm onSubmit={handleCreate} submitLabel="Add Contact" />
</div>
</div>
)}
{editContact && (
<div className="fixed inset-0 bg-black/60 flex items-center justify-center z-50 p-4">
<div className="bg-card border border-border rounded-lg w-full max-w-lg">
<div className="flex items-center justify-between px-6 py-4 border-b border-border">
<h2 className="font-sans text-base font-semibold text-foreground">Edit {editContact.name}</h2>
<button onClick={() => { setEditContact(null); resetForm() }} className="text-muted-foreground hover:text-foreground"><X className="w-4 h-4" /></button>
</div>
<ContactForm onSubmit={handleEdit} submitLabel="Save Changes" />
</div>
</div>
)}
</div>
)
}
+17
View File
@@ -0,0 +1,17 @@
import { createClient } from '@/lib/supabase/server'
import { ContactsClient } from './contacts-client'
export default async function ContactsPage() {
const supabase = await createClient()
const { data: { user } } = await supabase.auth.getUser()
if (!user) return null
const { data: member } = await supabase
.from('space_members').select('space_id').eq('user_id', user.id).eq('status', 'current').single()
if (!member) return null
const { data: contacts } = await supabase
.from('contacts').select('*').eq('space_id', member.space_id).order('name')
return <ContactsClient contacts={contacts ?? []} />
}
+189
View File
@@ -0,0 +1,189 @@
import { createClient } from '@/lib/supabase/server'
import Link from 'next/link'
import { redirect } from 'next/navigation'
function IcoUsers({ className }: { className?: string }) {
return <svg className={className} fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z" /></svg>
}
function IcoDues({ className }: { className?: string }) {
return <svg className={className} fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 7h8m0 0v8m0-8l-8 8-4-4-6 6" /></svg>
}
function IcoTask({ className }: { className?: string }) {
return <svg className={className} fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" /></svg>
}
function IcoPay({ className }: { className?: string }) {
return <svg className={className} fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 10h18M7 15h1m4 0h1m-7 4h12a3 3 0 003-3V8a3 3 0 00-3-3H6a3 3 0 00-3 3v8a3 3 0 003 3z" /></svg>
}
function IcoPlus({ className }: { className?: string }) {
return <svg className={className} fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" /></svg>
}
export default async function DashboardPage() {
const supabase = await createClient()
const { data: { user } } = await supabase.auth.getUser()
if (!user) redirect('/login')
const { data: member } = await supabase
.from('space_members')
.select('space_id, display_name, role')
.eq('user_id', user.id)
.in('status', ['current', 'unverified', 'late'])
.maybeSingle()
if (!member?.space_id) redirect('/signup')
const spaceId = member.space_id
const [
{ count: activeMembers },
{ count: openTasks },
{ count: unlinkedPayments },
{ data: tasks },
{ data: projects },
{ data: activity },
] = await Promise.all([
supabase.from('space_members').select('*', { count: 'exact', head: true }).eq('space_id', spaceId).eq('status', 'current'),
supabase.from('tasks').select('*', { count: 'exact', head: true }).eq('space_id', spaceId).in('status', ['open', 'claimed', 'in_progress']),
supabase.from('payments').select('*', { count: 'exact', head: true }).eq('space_id', spaceId).eq('link_status', 'unlinked'),
supabase.from('tasks').select('*').eq('space_id', spaceId).neq('status', 'done').order('due_date', { ascending: true, nullsFirst: false }).limit(5),
supabase.from('projects').select('*').eq('space_id', spaceId).in('status', ['in_progress', 'blocked', 'review']).limit(3),
supabase.from('activity_log').select('*').eq('space_id', spaceId).order('created_at', { ascending: false }).limit(6),
])
const overdueTasks = (tasks ?? []).filter((t: any) => t.due_date && new Date(t.due_date) < new Date())
const stats = [
{ label: 'Active Members', value: activeMembers ?? 0, Ico: IcoUsers, sub: 'in your space', warn: false },
{ label: 'Dues Current', value: Math.max(0, (activeMembers ?? 0) - (unlinkedPayments ?? 0)), Ico: IcoDues, sub: `${unlinkedPayments ?? 0} unpaid / unverified`, warn: (unlinkedPayments ?? 0) > 0 },
{ label: 'Open Tasks', value: openTasks ?? 0, Ico: IcoTask, sub: `${overdueTasks.length} overdue`, warn: (openTasks ?? 0) > 0 },
{ label: 'Unlinked Payments', value: unlinkedPayments ?? 0, Ico: IcoPay, sub: 'needs reconciliation', warn: (unlinkedPayments ?? 0) > 0 },
]
return (
<div className="min-h-screen bg-background">
<div className="bg-sidebar px-6 py-3 flex items-center justify-between">
<h1 className="text-white font-sans text-lg font-semibold">Dashboard</h1>
<Link href="/tasks" className="flex items-center gap-1.5 bg-primary text-white text-xs font-sans px-3 py-1.5 rounded hover:bg-primary/90 transition">
<IcoPlus className="w-3.5 h-3.5" />
Quick Task
</Link>
</div>
<div className="p-6 space-y-6">
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
{stats.map(({ label, value, Ico, sub, warn }) => (
<div key={label} className="bg-card rounded border border-border p-5">
<p className="font-mono text-[10px] tracking-widest text-muted-foreground uppercase mb-2 flex items-center gap-1.5">
<Ico className="w-3 h-3" /> {label}
</p>
<p className={`text-3xl font-sans font-bold ${warn ? 'text-orange-500' : 'text-primary'}`}>{value}</p>
<p className="font-sans text-xs text-muted-foreground mt-1">{sub}</p>
</div>
))}
</div>
<div className="grid lg:grid-cols-[1fr_300px] gap-6">
<div className="space-y-6">
<div>
<div className="flex items-center justify-between mb-3">
<p className="font-mono text-[10px] tracking-widest text-muted-foreground uppercase">Quick Chores</p>
<Link href="/tasks" className="font-sans text-xs text-primary hover:underline">All tasks</Link>
</div>
<div className="bg-card rounded border border-border divide-y divide-border">
{(tasks ?? []).length > 0 ? (tasks ?? []).map((task: any) => {
const isOverdue = task.due_date && new Date(task.due_date) < new Date()
const isDueToday = task.due_date && new Date(task.due_date).toDateString() === new Date().toDateString()
return (
<div key={task.id} className={`flex items-center gap-3 px-4 py-3 ${isOverdue ? 'border-l-2 border-red-400' : ''}`}>
<div className="w-4 h-4 rounded border-2 border-border flex-shrink-0" />
<div className="flex-1 min-w-0">
<p className="font-sans text-sm text-foreground truncate">{task.title}</p>
<p className="font-mono text-[10px] text-muted-foreground mt-0.5">
{[task.area, task.recurrence && task.recurrence !== 'none' ? `${task.recurrence}` : null, task.due_date ? `due ${new Date(task.due_date).toLocaleDateString('en-US', { month: 'short', day: 'numeric' })}` : null].filter(Boolean).join(' · ')}
</p>
</div>
{isOverdue ? (
<span className="font-mono text-[10px] text-red-600 bg-red-50 px-2 py-0.5 rounded">OVERDUE</span>
) : isDueToday ? (
<span className="font-mono text-[10px] text-orange-600 bg-orange-50 px-2 py-0.5 rounded">TODAY</span>
) : (
<span className="font-mono text-[10px] text-muted-foreground bg-muted px-2 py-0.5 rounded">OPEN</span>
)}
<Link href="/tasks" className="font-mono text-[10px] border border-border px-2 py-0.5 rounded hover:border-primary hover:text-primary transition">
CLAIM
</Link>
</div>
)
}) : (
<div className="px-4 py-10 text-center text-muted-foreground font-sans text-sm">No open tasks — great work!</div>
)}
</div>
</div>
<div>
<div className="flex items-center justify-between mb-3">
<p className="font-mono text-[10px] tracking-widest text-muted-foreground uppercase">Active Projects</p>
<Link href="/projects" className="font-sans text-xs text-primary hover:underline">All projects</Link>
</div>
<div className="bg-card rounded border border-border divide-y divide-border">
{(projects ?? []).length > 0 ? (projects ?? []).map((project: any) => (
<div key={project.id} className="px-4 py-4">
<div className="flex items-center justify-between mb-1">
<p className="font-sans text-sm font-medium text-foreground">{project.title}</p>
<span className={`font-mono text-[10px] px-2 py-0.5 rounded ${
project.status === 'in_progress' ? 'text-primary bg-primary/10' :
project.status === 'blocked' ? 'text-orange-600 bg-orange-50' : 'text-blue-600 bg-blue-50'
}`}>
{project.status.replace(/_/g, ' ').toUpperCase()}
</span>
</div>
{project.description && <p className="font-mono text-[10px] text-muted-foreground mb-2 truncate">{project.description}</p>}
<div className="h-1 bg-border rounded-full overflow-hidden">
<div className={`h-full rounded-full ${project.status === 'blocked' ? 'bg-orange-400' : 'bg-primary'}`} style={{ width: `${project.progress ?? 0}%` }} />
</div>
</div>
)) : (
<div className="px-4 py-8 text-center text-muted-foreground font-sans text-sm">No active projects</div>
)}
</div>
</div>
</div>
<div className="space-y-6">
<div>
<div className="flex items-center justify-between mb-3">
<p className="font-mono text-[10px] tracking-widest text-muted-foreground uppercase">Payment Alerts</p>
<Link href="/payments" className="font-sans text-xs text-primary hover:underline">Reconcile</Link>
</div>
<div className="bg-card rounded border border-border px-4 py-6 text-center">
<p className="font-sans text-xs text-muted-foreground">
<Link href="/settings" className="text-primary hover:underline">Connect a payment platform</Link>
{' '}to see alerts here.
</p>
</div>
</div>
<div>
<p className="font-mono text-[10px] tracking-widest text-muted-foreground uppercase mb-3">Recent Activity</p>
<div className="space-y-3">
{(activity ?? []).length > 0 ? (activity ?? []).map((item: any) => (
<div key={item.id} className="flex items-start gap-2.5">
<div className="w-1.5 h-1.5 rounded-full mt-2 flex-shrink-0 bg-primary" />
<div>
<p className="font-sans text-xs text-foreground">
<span className="font-medium">{item.display_name || 'System'}</span>{' '}{item.action}
</p>
<p className="font-mono text-[10px] text-muted-foreground">{new Date(item.created_at).toLocaleDateString()}</p>
</div>
</div>
)) : (
<p className="font-sans text-xs text-muted-foreground">No recent activity yet.</p>
)}
</div>
</div>
</div>
</div>
</div>
</div>
)
}
+146
View File
@@ -0,0 +1,146 @@
import { createClient } from '@/lib/supabase/server'
export default async function ImportPage() {
const supabase = await createClient()
const { data: { user } } = await supabase.auth.getUser()
const { data: member } = await supabase
.from('space_members').select('space_id, role').eq('user_id', user!.id).eq('status', 'current').single()
const isAdmin = member?.role === 'admin' || member?.role === 'board'
if (!isAdmin) {
return (
<div className="min-h-screen bg-background flex items-center justify-center">
<p className="font-sans text-sm text-muted-foreground">Admin access required</p>
</div>
)
}
return (
<div className="min-h-screen bg-background">
<div className="bg-sidebar px-6 py-3">
<h1 className="text-white font-sans text-lg font-semibold">Import & Sync</h1>
</div>
{/* Tabs */}
<div className="bg-card border-b border-border px-6 flex gap-6">
{['CSV / Excel', 'Database Connect', 'Webhooks & API', 'Integrations'].map((tab, i) => (
<button key={tab} className={`font-sans text-sm py-3 border-b-2 transition ${
i === 0 ? 'border-primary text-primary' : 'border-transparent text-muted-foreground hover:text-foreground'
}`}>
{tab}
</button>
))}
</div>
<div className="p-6 max-w-3xl">
{/* Steps */}
<div className="flex items-center gap-2 mb-8">
{[{ n: 1, label: 'Upload File', done: false }, { n: 2, label: 'Map Columns', active: false }, { n: 3, label: 'Preview' }, { n: 4, label: 'Import' }].map((step, i) => (
<div key={i} className="flex items-center gap-2">
<div className={`flex items-center gap-1.5 ${i === 0 ? 'text-primary' : 'text-muted-foreground'}`}>
<div className={`w-6 h-6 rounded-full flex items-center justify-center text-xs font-mono font-bold ${
i === 0 ? 'bg-primary text-white' : 'border-2 border-border'
}`}>
{i + 1}
</div>
<span className="font-sans text-sm">{step.label}</span>
</div>
{i < 3 && <span className="text-border">—</span>}
</div>
))}
</div>
{/* Upload area */}
<div className="bg-card rounded border border-border p-8 mb-6">
<div className="border-2 border-dashed border-border rounded-lg p-12 text-center hover:border-primary/50 transition cursor-pointer">
<svg className="w-10 h-10 text-muted-foreground mx-auto mb-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12" />
</svg>
<p className="font-sans text-sm text-muted-foreground mb-1">Drop your CSV or Excel file here</p>
<p className="font-mono text-xs text-muted-foreground/60">or click to browse</p>
</div>
</div>
{/* Column mapping preview */}
<div className="bg-card rounded border border-border overflow-hidden mb-6">
<div className="px-4 py-3 border-b border-border flex items-center justify-between">
<p className="font-mono text-[10px] tracking-widest text-muted-foreground uppercase">
Map Columns to App Fields
</p>
<p className="font-mono text-[10px] text-muted-foreground">Upload a file to begin mapping</p>
</div>
<div className="divide-y divide-border">
{[
['Full Name', 'display_name'],
['Email Address', 'email'],
['MemberType', 'tier'],
['JoinDate', 'joined_at'],
['PhoneNumber', 'phone'],
['LastDuesDate', 'last_paid_at'],
['CardAccess', 'has_card_access'],
].map(([col, field]) => (
<div key={col} className="grid grid-cols-[1fr_auto_1fr] items-center gap-3 px-4 py-3">
<div className="bg-muted/50 border border-border rounded px-3 py-2 font-mono text-xs text-muted-foreground">
{col}
</div>
<svg className="w-4 h-4 text-primary" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M14 5l7 7m0 0l-7 7m7-7H3" />
</svg>
<div className="bg-background border border-border rounded px-3 py-2 font-mono text-xs text-foreground">
{field}
</div>
</div>
))}
</div>
<div className="px-4 py-4 flex justify-end gap-3 border-t border-border">
<button className="bg-card border border-border text-foreground font-sans text-sm px-4 py-2 rounded hover:border-primary/50 transition">
Back
</button>
<button className="bg-primary text-white font-sans text-sm px-4 py-2 rounded hover:bg-primary/90 transition">
Preview Import →
</button>
</div>
</div>
{/* Database Connector */}
<div className="bg-card rounded border border-border p-6">
<p className="font-mono text-[10px] tracking-widest text-muted-foreground uppercase mb-4">
Database Connector (Advanced)
</p>
<p className="font-sans text-sm text-muted-foreground mb-4 leading-relaxed">
Connect directly to your existing member database. Supports PostgreSQL, MySQL, SQLite, and MongoDB.
Credentials are stored encrypted and never shared.
</p>
<div className="grid grid-cols-2 gap-4 mb-4">
<div>
<label className="font-sans text-sm text-foreground block mb-2">Database Type</label>
<select className="w-full bg-background border border-border rounded px-3 py-2 font-sans text-sm text-foreground focus:outline-none focus:border-primary">
<option>PostgreSQL (Ruby on Rails)</option>
<option>MySQL</option>
<option>SQLite</option>
<option>MongoDB</option>
</select>
</div>
<div>
<label className="font-sans text-sm text-foreground block mb-2">Connection String</label>
<input
type="text"
placeholder="postgres://user:pass@host:5432/db"
className="w-full bg-background border border-border rounded px-3 py-2 font-mono text-xs text-foreground placeholder:text-muted-foreground focus:outline-none focus:border-primary transition"
/>
</div>
</div>
<div className="flex gap-3">
<button className="border border-border bg-card text-foreground font-sans text-sm px-4 py-2 rounded hover:border-primary/50 transition">
Test Connection
</button>
<button className="bg-primary text-white font-sans text-sm px-4 py-2 rounded hover:bg-primary/90 transition">
Connect & Map Schema →
</button>
</div>
</div>
</div>
</div>
)
}
+53
View File
@@ -0,0 +1,53 @@
import { createClient } from '@/lib/supabase/server'
import { redirect } from 'next/navigation'
import { AppSidebar } from '@/components/app-sidebar'
export default async function AppLayout({ children }: { children: React.ReactNode }) {
const supabase = await createClient()
const { data: { user } } = await supabase.auth.getUser()
if (!user) redirect('/login')
const { data: member } = await supabase
.from('space_members')
.select('*, spaces(*)')
.eq('user_id', user.id)
.in('status', ['current', 'unverified', 'late'])
.order('joined_at', { ascending: true })
.limit(1)
.maybeSingle()
if (!member || !member.spaces) {
// No space found - redirect to signup to create/join one
const { data: { user: currentUser } } = await supabase.auth.getUser()
if (currentUser) {
redirect(`/signup?email=${encodeURIComponent(currentUser.email || '')}`)
}
redirect('/login')
}
// Count badges
const { count: taskCount } = await supabase
.from('tasks')
.select('*', { count: 'exact', head: true })
.eq('space_id', member.space_id)
.in('status', ['open', 'claimed'])
const { count: paymentCount } = await supabase
.from('payments')
.select('*', { count: 'exact', head: true })
.eq('space_id', member.space_id)
.eq('link_status', 'unlinked')
return (
<div className="flex h-screen bg-background overflow-hidden">
<AppSidebar
member={member as any}
taskBadge={taskCount ?? 0}
paymentBadge={paymentCount ?? 0}
/>
<main className="flex-1 overflow-y-auto">
{children}
</main>
</div>
)
}
+405
View File
@@ -0,0 +1,405 @@
'use client'
import { useState } from 'react'
import { Plus, X, ChevronDown } from 'lucide-react'
import { addMember, updateMember, approveMember, removeMember } from '@/lib/actions'
interface Member {
id: string
display_name: string
email: string
phone?: string
handle?: string
tier: string
role: string
status: string
joined_at?: string
last_paid_at?: string
last_payment_at?: string
payment_status?: string
payment_note?: string
has_card_access?: boolean
approved?: boolean
}
interface Props {
members: Member[]
currentRole: string
}
const TIER_COLORS: Record<string, string> = {
plus: 'text-blue-600 bg-blue-50 border-blue-200',
basic: 'text-muted-foreground bg-muted border-border',
associate: 'text-purple-600 bg-purple-50 border-purple-200',
admin: 'text-primary bg-primary/5 border-primary/20',
}
const isAdmin = (role: string) => role === 'admin' || role === 'board'
export function MembersClient({ members: initialMembers, currentRole }: Props) {
const [members, setMembers] = useState<Member[]>(initialMembers)
const [activeTab, setActiveTab] = useState<'all' | 'payment_issues' | 'unverified' | 'inactive'>('all')
const [search, setSearch] = useState('')
const [tierFilter, setTierFilter] = useState('')
const [showAdd, setShowAdd] = useState(false)
const [editMember, setEditMember] = useState<Member | null>(null)
const [loading, setLoading] = useState(false)
const [error, setError] = useState('')
// Add/Edit form
const [form, setForm] = useState({
display_name: '', email: '', phone: '', handle: '',
tier: 'basic', role: 'member', joined_at: '', has_card_access: false,
})
const total = members.length
const paymentIssues = members.filter(m => m.status === 'current' && m.payment_status && m.payment_status !== 'current').length
const unverified = members.filter(m => m.status === 'unverified').length
const filteredMembers = members.filter(m => {
const matchesTab =
activeTab === 'all' ? (m.status !== 'inactive') :
activeTab === 'payment_issues' ? (m.payment_status && m.payment_status !== 'current') :
activeTab === 'unverified' ? m.status === 'unverified' :
m.status === 'inactive'
const matchesSearch = !search ||
m.display_name.toLowerCase().includes(search.toLowerCase()) ||
m.email.toLowerCase().includes(search.toLowerCase()) ||
(m.handle?.toLowerCase().includes(search.toLowerCase()) ?? false)
const matchesTier = !tierFilter || m.tier === tierFilter
return matchesTab && matchesSearch && matchesTier
})
async function handleAdd(e: React.FormEvent) {
e.preventDefault()
setLoading(true)
setError('')
const result = await addMember(form)
if (result.error) { setError(result.error); setLoading(false); return }
if (result.data) setMembers(prev => [...prev, result.data as Member])
setShowAdd(false)
setForm({ display_name: '', email: '', phone: '', handle: '', tier: 'basic', role: 'member', joined_at: '', has_card_access: false })
setLoading(false)
}
async function handleEdit(e: React.FormEvent) {
e.preventDefault()
if (!editMember) return
setLoading(true)
setError('')
const result = await updateMember(editMember.id, form)
if (result.error) { setError(result.error); setLoading(false); return }
setMembers(prev => prev.map(m => m.id === editMember.id ? { ...m, ...form } : m))
setEditMember(null)
setLoading(false)
}
async function handleApprove(memberId: string) {
const result = await approveMember(memberId)
if (!result.error) {
setMembers(prev => prev.map(m => m.id === memberId ? { ...m, status: 'current', approved: true } : m))
}
}
async function handleRemove(memberId: string) {
if (!confirm('Remove this member from the space?')) return
const result = await removeMember(memberId)
if (!result.error) {
setMembers(prev => prev.filter(m => m.id !== memberId))
}
}
function openEdit(m: Member) {
setEditMember(m)
setForm({
display_name: m.display_name,
email: m.email,
phone: m.phone ?? '',
handle: m.handle ?? '',
tier: m.tier,
role: m.role,
joined_at: m.joined_at ?? '',
has_card_access: m.has_card_access ?? false,
})
setError('')
}
const MemberFormFields = () => (
<>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="font-mono text-[10px] tracking-widest text-muted-foreground uppercase block mb-1">Full Name *</label>
<input type="text" required value={form.display_name} onChange={e => setForm(f => ({ ...f, display_name: e.target.value }))}
className="w-full bg-background border border-border rounded px-3 py-2 font-sans text-sm text-foreground focus:outline-none focus:border-primary transition" />
</div>
<div>
<label className="font-mono text-[10px] tracking-widest text-muted-foreground uppercase block mb-1">Email *</label>
<input type="email" required value={form.email} onChange={e => setForm(f => ({ ...f, email: e.target.value }))}
className="w-full bg-background border border-border rounded px-3 py-2 font-sans text-sm text-foreground focus:outline-none focus:border-primary transition" />
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="font-mono text-[10px] tracking-widest text-muted-foreground uppercase block mb-1">Phone</label>
<input type="tel" value={form.phone} onChange={e => setForm(f => ({ ...f, phone: e.target.value }))}
className="w-full bg-background border border-border rounded px-3 py-2 font-sans text-sm text-foreground focus:outline-none focus:border-primary transition" />
</div>
<div>
<label className="font-mono text-[10px] tracking-widest text-muted-foreground uppercase block mb-1">Handle</label>
<input type="text" value={form.handle} placeholder="@username" onChange={e => setForm(f => ({ ...f, handle: e.target.value }))}
className="w-full bg-background border border-border rounded px-3 py-2 font-mono text-sm text-foreground focus:outline-none focus:border-primary transition" />
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="font-mono text-[10px] tracking-widest text-muted-foreground uppercase block mb-1">Tier</label>
<select value={form.tier} onChange={e => setForm(f => ({ ...f, tier: e.target.value }))}
className="w-full bg-background border border-border rounded px-3 py-2 font-sans text-sm text-foreground focus:outline-none focus:border-primary">
<option value="plus">Plus</option>
<option value="basic">Basic</option>
<option value="associate">Associate</option>
<option value="admin">Admin</option>
</select>
</div>
<div>
<label className="font-mono text-[10px] tracking-widest text-muted-foreground uppercase block mb-1">Role</label>
<select value={form.role} onChange={e => setForm(f => ({ ...f, role: e.target.value }))}
className="w-full bg-background border border-border rounded px-3 py-2 font-sans text-sm text-foreground focus:outline-none focus:border-primary">
<option value="member">Member</option>
<option value="board">Board</option>
<option value="treasurer">Treasurer</option>
<option value="admin">Admin</option>
</select>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="font-mono text-[10px] tracking-widest text-muted-foreground uppercase block mb-1">Joined At</label>
<input type="date" value={form.joined_at?.slice(0, 10)} onChange={e => setForm(f => ({ ...f, joined_at: e.target.value }))}
className="w-full bg-background border border-border rounded px-3 py-2 font-sans text-sm text-foreground focus:outline-none focus:border-primary" />
</div>
<div className="flex items-center gap-3 pt-6">
<label className="flex items-center gap-2 cursor-pointer">
<input type="checkbox" checked={form.has_card_access} onChange={e => setForm(f => ({ ...f, has_card_access: e.target.checked }))}
className="w-4 h-4 accent-primary" />
<span className="font-sans text-sm text-foreground">Card Access</span>
</label>
</div>
</div>
</>
)
return (
<div className="min-h-screen bg-background">
<div className="bg-sidebar px-6 py-3 flex items-center justify-between">
<div className="flex items-center gap-3">
<h1 className="text-white font-sans text-lg font-semibold">Members</h1>
<span className="font-mono text-xs text-white/50">{total} total</span>
</div>
{isAdmin(currentRole) && (
<button
onClick={() => setShowAdd(true)}
className="flex items-center gap-1.5 bg-primary text-white text-xs font-sans px-3 py-1.5 rounded hover:bg-primary/90 transition"
>
<Plus className="w-3.5 h-3.5" /> Add Member
</button>
)}
</div>
<div className="bg-card border-b border-border px-6 flex gap-6">
{[
{ key: 'all', label: `All ${total}` },
{ key: 'payment_issues', label: `Payment Issues ${paymentIssues}` },
{ key: 'unverified', label: `Pending Approval ${unverified}` },
{ key: 'inactive', label: 'Inactive' },
].map(({ key, label }) => (
<button
key={key}
onClick={() => setActiveTab(key as typeof activeTab)}
className={`font-sans text-sm py-3 border-b-2 transition ${
activeTab === key ? 'border-primary text-primary' : 'border-transparent text-muted-foreground hover:text-foreground'
}`}
>
{label}
</button>
))}
</div>
<div className="p-6">
<div className="flex gap-3 mb-4">
<div className="flex-1 relative">
<svg className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
</svg>
<input
type="text"
placeholder="Search by name, email, handle..."
value={search}
onChange={e => setSearch(e.target.value)}
className="w-full bg-card border border-border rounded pl-9 pr-4 py-2 font-sans text-sm text-foreground placeholder:text-muted-foreground focus:outline-none focus:border-primary transition"
/>
</div>
<select
value={tierFilter}
onChange={e => setTierFilter(e.target.value)}
className="bg-card border border-border text-foreground text-sm font-sans rounded px-3 py-2 focus:outline-none focus:border-primary"
>
<option value="">All Tiers</option>
<option value="plus">Plus</option>
<option value="basic">Basic</option>
<option value="associate">Associate</option>
</select>
</div>
<div className="bg-card rounded border border-border overflow-hidden">
<table className="w-full">
<thead>
<tr className="border-b border-border">
<th className="px-4 py-3 text-left font-mono text-[10px] tracking-widest text-muted-foreground">MEMBER</th>
<th className="px-4 py-3 text-left font-mono text-[10px] tracking-widest text-muted-foreground">TIER</th>
<th className="px-4 py-3 text-left font-mono text-[10px] tracking-widest text-muted-foreground">JOINED</th>
<th className="px-4 py-3 text-left font-mono text-[10px] tracking-widest text-muted-foreground">LAST PAYMENT</th>
<th className="px-4 py-3 text-left font-mono text-[10px] tracking-widest text-muted-foreground">STATUS</th>
{isAdmin(currentRole) && (
<th className="px-4 py-3 text-left font-mono text-[10px] tracking-widest text-muted-foreground">ACTIONS</th>
)}
</tr>
</thead>
<tbody className="divide-y divide-border">
{filteredMembers.length > 0 ? filteredMembers.map(m => {
const initials = (m.display_name || 'U').split(' ').map((n: string) => n[0]).join('').toUpperCase().slice(0, 2)
const hasIssue = m.payment_status && m.payment_status !== 'current'
return (
<tr key={m.id} className={`hover:bg-muted/30 transition ${hasIssue ? 'bg-red-50/20' : ''}`}>
<td className="px-4 py-3">
<div className="flex items-center gap-3">
<div className={`w-8 h-8 rounded flex items-center justify-center text-[10px] font-mono font-bold flex-shrink-0 ${
hasIssue ? 'bg-red-100 text-red-600' : 'bg-primary/10 text-primary'
}`}>
{initials}
</div>
<div>
<p className="font-sans text-sm font-medium text-foreground">{m.display_name}</p>
<p className={`font-mono text-[10px] ${hasIssue ? 'text-red-500' : 'text-muted-foreground'}`}>
{m.email}{m.handle ? ` · @${m.handle}` : ''}{m.payment_note ? ` — ${m.payment_note}` : ''}
</p>
</div>
</div>
</td>
<td className="px-4 py-3">
<span className={`font-mono text-[10px] px-2 py-0.5 rounded border ${TIER_COLORS[m.tier?.toLowerCase()] ?? 'text-muted-foreground bg-muted border-border'}`}>
{m.tier?.toUpperCase()}
</span>
</td>
<td className="px-4 py-3 font-mono text-xs text-muted-foreground">
{m.joined_at ? new Date(m.joined_at).toLocaleDateString('en-US', { month: 'short', year: 'numeric' }) : '—'}
</td>
<td className="px-4 py-3 font-mono text-xs text-muted-foreground">
{(m.last_paid_at || m.last_payment_at) ? new Date((m.last_paid_at || m.last_payment_at)!).toLocaleDateString('en-US', { month: 'short', year: 'numeric' }) : '—'}
</td>
<td className="px-4 py-3">
{m.status === 'unverified' ? (
<span className="flex items-center gap-1 font-mono text-xs text-orange-600">
<span className="w-1.5 h-1.5 rounded-full bg-orange-400" /> unverified
</span>
) : m.payment_status === 'current' ? (
<span className="flex items-center gap-1 font-mono text-xs text-primary">
<span className="w-1.5 h-1.5 rounded-full bg-primary" /> current
</span>
) : m.payment_status ? (
<span className="flex items-center gap-1 font-mono text-xs text-red-600">
<span className="w-1.5 h-1.5 rounded-full bg-red-500" /> {m.payment_status}
</span>
) : (
<span className="font-mono text-xs text-muted-foreground">{m.status}</span>
)}
</td>
{isAdmin(currentRole) && (
<td className="px-4 py-3">
<div className="flex items-center gap-2">
{m.status === 'unverified' && (
<button
onClick={() => handleApprove(m.id)}
className="font-mono text-[10px] border border-primary/30 text-primary bg-primary/5 px-2 py-0.5 rounded hover:bg-primary/10 transition"
>
APPROVE
</button>
)}
<button
onClick={() => openEdit(m)}
className="font-mono text-[10px] border border-border px-2 py-0.5 rounded hover:border-primary hover:text-primary transition"
>
EDIT
</button>
<button
onClick={() => handleRemove(m.id)}
className="font-mono text-[10px] border border-border px-2 py-0.5 rounded hover:border-red-300 hover:text-red-600 transition"
>
REMOVE
</button>
</div>
</td>
)}
</tr>
)
}) : (
<tr>
<td colSpan={isAdmin(currentRole) ? 6 : 5} className="px-4 py-12 text-center font-sans text-sm text-muted-foreground">
No members match this filter
</td>
</tr>
)}
</tbody>
</table>
</div>
</div>
{/* Add Member Modal */}
{showAdd && (
<div className="fixed inset-0 bg-black/60 flex items-center justify-center z-50 p-4">
<div className="bg-card border border-border rounded-lg w-full max-w-lg">
<div className="flex items-center justify-between px-6 py-4 border-b border-border">
<h2 className="font-sans text-base font-semibold text-foreground">Add Member</h2>
<button onClick={() => setShowAdd(false)} className="text-muted-foreground hover:text-foreground"><X className="w-4 h-4" /></button>
</div>
<form onSubmit={handleAdd} className="p-6 space-y-4">
<MemberFormFields />
{error && <p className="font-mono text-xs text-red-500">{error}</p>}
<div className="flex gap-3 pt-2">
<button type="button" onClick={() => setShowAdd(false)} className="flex-1 border border-border text-foreground font-sans text-sm py-2 rounded hover:border-primary/50 transition">Cancel</button>
<button type="submit" disabled={loading} className="flex-1 bg-primary text-white font-sans text-sm py-2 rounded hover:bg-primary/90 transition disabled:opacity-60">
{loading ? 'Adding...' : 'Add Member'}
</button>
</div>
</form>
</div>
</div>
)}
{/* Edit Member Modal */}
{editMember && (
<div className="fixed inset-0 bg-black/60 flex items-center justify-center z-50 p-4">
<div className="bg-card border border-border rounded-lg w-full max-w-lg">
<div className="flex items-center justify-between px-6 py-4 border-b border-border">
<h2 className="font-sans text-base font-semibold text-foreground">Edit {editMember.display_name}</h2>
<button onClick={() => setEditMember(null)} className="text-muted-foreground hover:text-foreground"><X className="w-4 h-4" /></button>
</div>
<form onSubmit={handleEdit} className="p-6 space-y-4">
<MemberFormFields />
{error && <p className="font-mono text-xs text-red-500">{error}</p>}
<div className="flex gap-3 pt-2">
<button type="button" onClick={() => setEditMember(null)} className="flex-1 border border-border text-foreground font-sans text-sm py-2 rounded hover:border-primary/50 transition">Cancel</button>
<button type="submit" disabled={loading} className="flex-1 bg-primary text-white font-sans text-sm py-2 rounded hover:bg-primary/90 transition disabled:opacity-60">
{loading ? 'Saving...' : 'Save Changes'}
</button>
</div>
</form>
</div>
</div>
)}
</div>
)
}
+30
View File
@@ -0,0 +1,30 @@
import { createClient } from '@/lib/supabase/server'
import { MembersClient } from './members-client'
export default async function MembersPage() {
const supabase = await createClient()
const { data: { user } } = await supabase.auth.getUser()
if (!user) return null
const { data: self } = await supabase
.from('space_members')
.select('space_id, role')
.eq('user_id', user.id)
.eq('status', 'current')
.single()
if (!self) return null
const { data: members } = await supabase
.from('space_members')
.select('*')
.eq('space_id', self.space_id)
.order('joined_at')
return (
<MembersClient
members={members ?? []}
currentRole={self.role}
/>
)
}
+195
View File
@@ -0,0 +1,195 @@
import { createClient } from '@/lib/supabase/server'
import Link from 'next/link'
import { Plus, Lock } from 'lucide-react'
export default async function OpsPage() {
const supabase = await createClient()
const { data: { user } } = await supabase.auth.getUser()
if (!user) return null
const { data: member } = await supabase
.from('space_members').select('space_id, role').eq('user_id', user.id).eq('status', 'current').single()
if (!member) return null
const [{ data: kbEntries }, { data: areaLeads }, { data: secrets }] = await Promise.all([
supabase.from('knowledge_base').select('*').eq('space_id', member!.space_id).order('is_pinned', { ascending: false }).order('created_at', { ascending: false }).limit(20),
supabase.from('area_leads').select('*').eq('space_id', member!.space_id).order('area_name'),
member?.role === 'admin' || member?.role === 'board'
? supabase.from('secrets').select('id, title, area').eq('space_id', member!.space_id)
: Promise.resolve({ data: [] }),
])
const pinned = kbEntries?.filter(e => e.is_pinned) ?? []
const byArea = kbEntries?.filter(e => !e.is_pinned) ?? []
const accessColors: Record<string, string> = {
admin_only: 'text-red-600 bg-red-50 border-red-200',
board: 'text-amber-600 bg-amber-50 border-amber-200',
all_members: 'text-primary bg-primary/5 border-primary/20',
}
return (
<div className="min-h-screen bg-background">
<div className="bg-sidebar px-6 py-3 flex items-center justify-between">
<h1 className="text-white font-sans text-lg font-semibold">Ops & Facilities</h1>
<Link href="/ops/new" className="flex items-center gap-1.5 bg-primary text-white text-xs font-sans px-3 py-1.5 rounded hover:bg-primary/90 transition">
<Plus className="w-3.5 h-3.5" /> Add Entry
</Link>
</div>
{/* Tabs */}
<div className="bg-card border-b border-border px-6 flex gap-6">
{['Knowledge Base', 'Processes', 'Secrets & Credentials', 'Area Leads'].map((tab, i) => (
<button key={tab} className={`font-sans text-sm py-3 border-b-2 transition ${
i === 0 ? 'border-primary text-primary' : 'border-transparent text-muted-foreground hover:text-foreground'
}`}>
{tab}
</button>
))}
</div>
<div className="p-6 grid lg:grid-cols-[1fr_280px] gap-6">
<div className="space-y-6">
{/* Search */}
<div className="relative">
<svg className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
</svg>
<input
type="text"
placeholder="Search knowledge base..."
className="w-full bg-card border border-border rounded pl-9 pr-4 py-2.5 font-sans text-sm text-foreground placeholder:text-muted-foreground focus:outline-none focus:border-primary transition"
/>
</div>
{/* Pinned / Critical */}
{pinned.length > 0 && (
<div>
<p className="font-mono text-[10px] tracking-widest text-muted-foreground uppercase mb-3">
Pinned / Critical
</p>
<div className="bg-card rounded border border-border divide-y divide-border">
{pinned.map(entry => (
<Link key={entry.id} href={`/ops/${entry.id}`}>
<div className={`flex items-start gap-3 px-4 py-4 hover:bg-muted/30 transition border-l-4 ${
entry.visibility === 'admin_only' ? 'border-l-red-400' :
entry.visibility === 'board' ? 'border-l-amber-400' : 'border-l-primary'
}`}>
<div className="w-8 h-8 rounded bg-muted flex items-center justify-center flex-shrink-0">
{entry.icon || <Lock className="w-4 h-4 text-muted-foreground" />}
</div>
<div className="flex-1 min-w-0">
<p className="font-sans text-sm font-medium text-foreground">{entry.title}</p>
<p className="font-mono text-[10px] text-muted-foreground mt-0.5">{entry.content?.slice(0, 100)}</p>
<p className="font-mono text-[10px] text-muted-foreground/60 mt-1">
updated {new Date(entry.updated_at).toLocaleDateString()} by {entry.updated_by_name}
</p>
</div>
<span className={`font-mono text-[10px] px-2 py-0.5 rounded border whitespace-nowrap flex-shrink-0 ${
accessColors[entry.visibility] ?? 'text-muted-foreground bg-muted border-border'
}`}>
{entry.visibility?.toUpperCase().replace('_', ' ') ?? 'ALL MEMBERS'}
</span>
</div>
</Link>
))}
</div>
</div>
)}
{/* By Area */}
{byArea.length > 0 && (
<div>
<p className="font-mono text-[10px] tracking-widest text-muted-foreground uppercase mb-3">By Area</p>
<div className="bg-card rounded border border-border divide-y divide-border">
{byArea.map(entry => (
<Link key={entry.id} href={`/ops/${entry.id}`}>
<div className="flex items-start gap-3 px-4 py-4 hover:bg-muted/30 transition">
<div className="w-8 h-8 rounded bg-muted flex items-center justify-center flex-shrink-0">
<svg className="w-4 h-4 text-muted-foreground" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
</svg>
</div>
<div className="flex-1 min-w-0">
<p className="font-sans text-sm font-medium text-foreground">{entry.title}</p>
<p className="font-mono text-[10px] text-muted-foreground mt-0.5">{entry.content?.slice(0, 100)}</p>
<p className="font-mono text-[10px] text-muted-foreground/60 mt-1">
{entry.area} · updated {new Date(entry.updated_at).toLocaleDateString()}
</p>
</div>
</div>
</Link>
))}
</div>
</div>
)}
{kbEntries?.length === 0 && (
<div className="bg-card rounded border border-dashed border-border p-12 text-center">
<p className="font-sans text-sm text-muted-foreground">No knowledge base entries yet</p>
<Link href="/ops/new" className="font-mono text-xs text-primary mt-2 block hover:underline">+ Add first entry</Link>
</div>
)}
</div>
{/* Right panel */}
<div className="space-y-6">
<div>
<div className="flex items-center justify-between mb-3">
<p className="font-mono text-[10px] tracking-widest text-muted-foreground uppercase">Area Leads</p>
<button className="font-mono text-[10px] border border-border px-2 py-0.5 rounded hover:border-primary hover:text-primary transition">
Manage
</button>
</div>
<div className="bg-card rounded border border-border divide-y divide-border">
{areaLeads && areaLeads.length > 0 ? areaLeads.map(lead => (
<div key={lead.id} className="flex items-center gap-3 px-3 py-3">
<div className="w-8 h-8 rounded bg-primary/10 flex items-center justify-center text-[10px] font-mono font-bold text-primary flex-shrink-0">
{lead.area_name.slice(0, 2).toUpperCase()}
</div>
<div className="flex-1 min-w-0">
<p className="font-sans text-xs font-medium text-foreground truncate">{lead.area_name}</p>
<p className="font-mono text-[10px] text-muted-foreground truncate">
{lead.lead_handle ? `lead: ${lead.lead_handle}` : 'unassigned'}
</p>
</div>
<span className={`font-mono text-[10px] px-1.5 py-0.5 rounded border flex-shrink-0 ${
lead.status === 'active' ? 'text-primary border-primary/30 bg-primary/5' :
lead.status === 'vacant' ? 'text-muted-foreground border-border bg-muted' :
'text-amber-600 border-amber-300 bg-amber-50'
}`}>
{lead.status?.toUpperCase()}
</span>
</div>
)) : (
<div className="px-3 py-6 text-center text-muted-foreground font-sans text-xs">No area leads configured</div>
)}
</div>
</div>
{/* Secrets Vault */}
<div>
<p className="font-mono text-[10px] tracking-widest text-muted-foreground uppercase mb-3">Secrets Vault</p>
<div className="bg-card rounded border border-border p-4">
<p className="font-sans text-xs text-muted-foreground mb-3 leading-relaxed">
Credentials are encrypted and role-scoped. Only designated leads can access their area.
</p>
{secrets && secrets.length > 0 ? (
<div className="space-y-2">
{secrets.map(s => (
<div key={s.id} className="flex items-center gap-2">
<Lock className="w-3 h-3 text-muted-foreground flex-shrink-0" />
<span className="font-mono text-xs text-foreground truncate">{s.title}</span>
</div>
))}
</div>
) : (
<p className="font-mono text-xs text-muted-foreground">No secrets stored yet</p>
)}
</div>
</div>
</div>
</div>
</div>
)
}
+37
View File
@@ -0,0 +1,37 @@
import { createClient } from '@/lib/supabase/server'
import { PaymentsClient } from './payments-client'
export default async function PaymentsPage() {
const supabase = await createClient()
const { data: { user } } = await supabase.auth.getUser()
if (!user) return null
const { data: member } = await supabase
.from('space_members').select('space_id, role').eq('user_id', user.id).eq('status', 'current').single()
if (!member) return null
const [{ data: payments }, { data: members }, { data: integrations }] = await Promise.all([
supabase.from('payments')
.select('*, space_members(display_name)')
.eq('space_id', member.space_id)
.order('transaction_date', { ascending: false })
.limit(100),
supabase.from('space_members')
.select('id, display_name, email')
.eq('space_id', member.space_id)
.eq('status', 'current'),
supabase.from('integrations')
.select('platform, is_connected, config')
.eq('space_id', member.space_id),
])
return (
<PaymentsClient
payments={payments ?? []}
members={members ?? []}
integrations={integrations ?? []}
currentRole={member.role}
spaceId={member.space_id}
/>
)
}
+314
View File
@@ -0,0 +1,314 @@
'use client'
import { useState } from 'react'
import { X } from 'lucide-react'
import { logCashPayment, linkPaymentToMember } from '@/lib/actions'
interface Payment {
id: string
platform: string
amount: number
from_identifier: string
from_note?: string
transaction_date: string
link_status: string
member_id?: string
space_members?: { display_name: string } | null
}
interface Member { id: string; display_name: string; email: string }
interface Integration { platform: string; is_connected: boolean; config?: Record<string, string> }
interface Props {
payments: Payment[]
members: Member[]
integrations: Integration[]
currentRole: string
spaceId: string
}
const platformColors: Record<string, string> = {
paypal: 'text-blue-600', zeffy: 'text-purple-600', venmo: 'text-green-600', cash: 'text-muted-foreground',
}
const canEdit = (role: string) => ['admin', 'board', 'treasurer'].includes(role)
export function PaymentsClient({ payments: initialPayments, members, integrations, currentRole, spaceId }: Props) {
const [payments, setPayments] = useState<Payment[]>(initialPayments)
const [platformFilter, setPlatformFilter] = useState('')
const [statusFilter, setStatusFilter] = useState('')
const [showLogCash, setShowLogCash] = useState(false)
const [linkingPayment, setLinkingPayment] = useState<Payment | null>(null)
const [loading, setLoading] = useState(false)
const [error, setError] = useState('')
// Cash form
const [cashForm, setCashForm] = useState({ amount: '', from_note: '', member_id: '', date: '' })
const platforms = ['paypal', 'zeffy', 'venmo', 'cash']
const filtered = payments.filter(p => {
const matchPlatform = !platformFilter || p.platform === platformFilter
const matchStatus = !statusFilter || p.link_status === statusFilter
return matchPlatform && matchStatus
})
const byPlatform = platforms.reduce<Record<string, Payment[]>>((acc, platform) => {
acc[platform] = payments.filter(p => p.platform === platform)
return acc
}, {})
const unlinkedCount = payments.filter(p => p.link_status === 'unlinked').length
async function handleLogCash(e: React.FormEvent) {
e.preventDefault()
setLoading(true)
setError('')
const result = await logCashPayment({
amount: parseFloat(cashForm.amount),
from_note: cashForm.from_note,
member_id: cashForm.member_id || undefined,
transaction_date: cashForm.date || undefined,
})
if (result.error) { setError(result.error); setLoading(false); return }
if (result.data) {
const linkedMember = cashForm.member_id ? members.find(m => m.id === cashForm.member_id) : null
setPayments(prev => [{
...result.data as Payment,
space_members: linkedMember ? { display_name: linkedMember.display_name } : null,
}, ...prev])
}
setShowLogCash(false)
setCashForm({ amount: '', from_note: '', member_id: '', date: '' })
setLoading(false)
}
async function handleLink(paymentId: string, memberId: string) {
setLoading(true)
const result = await linkPaymentToMember(paymentId, memberId)
if (!result.error) {
const linkedMember = members.find(m => m.id === memberId)
setPayments(prev => prev.map(p => p.id === paymentId ? {
...p,
member_id: memberId,
link_status: 'linked',
space_members: linkedMember ? { display_name: linkedMember.display_name } : null,
} : p))
setLinkingPayment(null)
}
setLoading(false)
}
return (
<div className="min-h-screen bg-background">
<div className="bg-sidebar px-6 py-3 flex items-center justify-between">
<div className="flex items-center gap-3">
<h1 className="text-white font-sans text-lg font-semibold">Payment Reconciliation</h1>
{unlinkedCount > 0 && (
<span className="font-mono text-xs text-orange-400">{unlinkedCount} unlinked</span>
)}
</div>
{canEdit(currentRole) && (
<div className="flex items-center gap-2">
<button
onClick={() => setShowLogCash(true)}
className="bg-sidebar-accent border border-sidebar-border text-sidebar-foreground text-xs font-sans px-3 py-1.5 rounded hover:bg-sidebar-accent/80 transition"
>
Log Cash
</button>
</div>
)}
</div>
<div className="p-6 space-y-6">
{/* Platform summary cards */}
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
{platforms.map(platform => {
const txs = byPlatform[platform] ?? []
const total = txs.reduce((sum, t) => sum + (t.amount ?? 0), 0)
const unlinked = txs.filter(t => t.link_status === 'unlinked').length
const integration = integrations.find(i => i.platform === platform)
return (
<div key={platform} className="bg-card rounded border border-border p-5">
<div className="flex items-center justify-between mb-2">
<p className={`font-mono text-[10px] tracking-widest font-bold ${platformColors[platform]}`}>
{platform.toUpperCase()}
</p>
{platform !== 'cash' && (
<span className={`font-mono text-[10px] px-1.5 py-0.5 rounded border ${
integration?.is_connected ? 'text-primary border-primary/30 bg-primary/5' : 'text-muted-foreground border-border bg-muted'
}`}>
{integration?.is_connected ? 'LIVE' : 'NOT CONNECTED'}
</span>
)}
</div>
<p className="text-2xl font-sans font-bold text-foreground">${total.toFixed(0)}</p>
<p className="font-mono text-[10px] text-muted-foreground mt-1">
{txs.length} tx{txs.length !== 1 ? 's' : ''} · {unlinked} unlinked
</p>
</div>
)
})}
</div>
{/* Transactions table */}
<div>
<div className="flex items-center justify-between mb-3">
<p className="font-mono text-[10px] tracking-widest text-muted-foreground uppercase">All Transactions</p>
<div className="flex gap-2">
<select value={platformFilter} onChange={e => setPlatformFilter(e.target.value)}
className="bg-card border border-border text-foreground text-xs font-sans rounded px-2 py-1 focus:outline-none focus:border-primary">
<option value="">All Platforms</option>
{platforms.map(p => <option key={p} value={p}>{p.charAt(0).toUpperCase() + p.slice(1)}</option>)}
</select>
<select value={statusFilter} onChange={e => setStatusFilter(e.target.value)}
className="bg-card border border-border text-foreground text-xs font-sans rounded px-2 py-1 focus:outline-none focus:border-primary">
<option value="">All Status</option>
<option value="linked">Linked</option>
<option value="unlinked">Unlinked</option>
</select>
</div>
</div>
<div className="bg-card rounded border border-border overflow-hidden">
<table className="w-full">
<thead>
<tr className="border-b border-border">
{['PLATFORM', 'AMOUNT', 'FROM / NOTE', 'DATE', 'MEMBER'].map(h => (
<th key={h} className="px-4 py-3 text-left font-mono text-[10px] tracking-widest text-muted-foreground">{h}</th>
))}
</tr>
</thead>
<tbody className="divide-y divide-border">
{filtered.length > 0 ? filtered.map(p => (
<tr key={p.id} className={`hover:bg-muted/30 transition ${p.link_status === 'linked' ? 'bg-primary/3' : ''}`}>
<td className={`px-4 py-3 font-mono text-[10px] font-bold ${platformColors[p.platform] ?? ''}`}>
{p.platform?.toUpperCase()}
</td>
<td className="px-4 py-3 font-sans text-sm font-medium">${Number(p.amount).toFixed(2)}</td>
<td className="px-4 py-3 font-mono text-xs text-muted-foreground max-w-[200px] truncate">
{p.from_identifier}{p.from_note ? ` — "${p.from_note}"` : ''}
</td>
<td className="px-4 py-3 font-mono text-xs text-muted-foreground">
{p.transaction_date ? new Date(p.transaction_date).toLocaleDateString('en-US', { month: 'short', day: 'numeric' }) : '—'}
</td>
<td className="px-4 py-3">
{p.link_status === 'linked' && p.space_members ? (
<span className="flex items-center gap-1 font-mono text-[10px] text-primary bg-primary/5 px-2 py-0.5 rounded border border-primary/20">
<svg className="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
{p.space_members.display_name}
</span>
) : canEdit(currentRole) ? (
<button
onClick={() => setLinkingPayment(p)}
className="font-mono text-[10px] border border-dashed border-border px-2 py-0.5 rounded hover:border-primary hover:text-primary transition"
>
+ Link member
</button>
) : (
<span className="font-mono text-xs text-muted-foreground">unlinked</span>
)}
</td>
</tr>
)) : (
<tr>
<td colSpan={5} className="px-4 py-12 text-center font-sans text-sm text-muted-foreground">
No payments recorded yet
</td>
</tr>
)}
</tbody>
</table>
</div>
</div>
</div>
{/* Log Cash Modal */}
{showLogCash && (
<div className="fixed inset-0 bg-black/60 flex items-center justify-center z-50 p-4">
<div className="bg-card border border-border rounded-lg w-full max-w-md">
<div className="flex items-center justify-between px-6 py-4 border-b border-border">
<h2 className="font-sans text-base font-semibold text-foreground">Log Cash Payment</h2>
<button onClick={() => setShowLogCash(false)} className="text-muted-foreground hover:text-foreground"><X className="w-4 h-4" /></button>
</div>
<form onSubmit={handleLogCash} className="p-6 space-y-4">
<div className="grid grid-cols-2 gap-4">
<div>
<label className="font-mono text-[10px] tracking-widest text-muted-foreground uppercase block mb-1">Amount *</label>
<input type="number" step="0.01" min="0" required value={cashForm.amount}
onChange={e => setCashForm(f => ({ ...f, amount: e.target.value }))}
placeholder="50.00"
className="w-full bg-background border border-border rounded px-3 py-2 font-sans text-sm text-foreground focus:outline-none focus:border-primary transition" />
</div>
<div>
<label className="font-mono text-[10px] tracking-widest text-muted-foreground uppercase block mb-1">Date</label>
<input type="date" value={cashForm.date}
onChange={e => setCashForm(f => ({ ...f, date: e.target.value }))}
className="w-full bg-background border border-border rounded px-3 py-2 font-sans text-sm text-foreground focus:outline-none focus:border-primary" />
</div>
</div>
<div>
<label className="font-mono text-[10px] tracking-widest text-muted-foreground uppercase block mb-1">Note / From *</label>
<input type="text" required value={cashForm.from_note}
onChange={e => setCashForm(f => ({ ...f, from_note: e.target.value }))}
placeholder="John Smith — March dues"
className="w-full bg-background border border-border rounded px-3 py-2 font-sans text-sm text-foreground focus:outline-none focus:border-primary transition" />
</div>
<div>
<label className="font-mono text-[10px] tracking-widest text-muted-foreground uppercase block mb-1">Link to Member</label>
<select value={cashForm.member_id} onChange={e => setCashForm(f => ({ ...f, member_id: e.target.value }))}
className="w-full bg-background border border-border rounded px-3 py-2 font-sans text-sm text-foreground focus:outline-none focus:border-primary">
<option value="">— Unlinked —</option>
{members.map(m => <option key={m.id} value={m.id}>{m.display_name} ({m.email})</option>)}
</select>
</div>
{error && <p className="font-mono text-xs text-red-500">{error}</p>}
<div className="flex gap-3 pt-2">
<button type="button" onClick={() => setShowLogCash(false)} className="flex-1 border border-border text-foreground font-sans text-sm py-2 rounded hover:border-primary/50 transition">Cancel</button>
<button type="submit" disabled={loading} className="flex-1 bg-primary text-white font-sans text-sm py-2 rounded hover:bg-primary/90 transition disabled:opacity-60">
{loading ? 'Logging...' : 'Log Payment'}
</button>
</div>
</form>
</div>
</div>
)}
{/* Link Member Modal */}
{linkingPayment && (
<div className="fixed inset-0 bg-black/60 flex items-center justify-center z-50 p-4">
<div className="bg-card border border-border rounded-lg w-full max-w-md">
<div className="flex items-center justify-between px-6 py-4 border-b border-border">
<div>
<h2 className="font-sans text-base font-semibold text-foreground">Link to Member</h2>
<p className="font-mono text-xs text-muted-foreground mt-0.5">
${linkingPayment.amount} via {linkingPayment.platform?.toUpperCase()} — {linkingPayment.from_identifier}
</p>
</div>
<button onClick={() => setLinkingPayment(null)} className="text-muted-foreground hover:text-foreground"><X className="w-4 h-4" /></button>
</div>
<div className="p-4 max-h-80 overflow-y-auto divide-y divide-border">
{members.map(m => (
<button
key={m.id}
onClick={() => handleLink(linkingPayment.id, m.id)}
disabled={loading}
className="w-full flex items-center gap-3 py-3 hover:bg-muted/30 transition text-left disabled:opacity-50"
>
<div className="w-8 h-8 rounded bg-primary/10 flex items-center justify-center text-[10px] font-mono font-bold text-primary flex-shrink-0">
{m.display_name.split(' ').map(n => n[0]).join('').toUpperCase().slice(0, 2)}
</div>
<div>
<p className="font-sans text-sm text-foreground">{m.display_name}</p>
<p className="font-mono text-[10px] text-muted-foreground">{m.email}</p>
</div>
</button>
))}
</div>
</div>
</div>
)}
</div>
)
}
+17
View File
@@ -0,0 +1,17 @@
import { createClient } from '@/lib/supabase/server'
import { ProjectsClient } from './projects-client'
export default async function ProjectsPage() {
const supabase = await createClient()
const { data: { user } } = await supabase.auth.getUser()
if (!user) return null
const { data: member } = await supabase
.from('space_members').select('space_id').eq('user_id', user.id).eq('status', 'current').single()
if (!member) return null
const { data: projects } = await supabase
.from('projects').select('*').eq('space_id', member.space_id).order('created_at', { ascending: false })
return <ProjectsClient projects={projects ?? []} spaceId={member.space_id} />
}
+199
View File
@@ -0,0 +1,199 @@
'use client'
import { useState } from 'react'
import { Plus, X } from 'lucide-react'
import { createProject, updateProjectStatus, deleteProject } from '@/lib/actions'
interface Project {
id: string
title: string
description?: string
status: string
area?: string
tags?: string[]
progress?: number
due_date?: string
created_at: string
}
const STATUS_COLS = [
{ key: 'backlog', label: 'BACKLOG', color: 'text-muted-foreground', dot: 'bg-muted-foreground' },
{ key: 'in_progress', label: 'IN PROGRESS', color: 'text-primary', dot: 'bg-primary' },
{ key: 'review', label: 'REVIEW', color: 'text-blue-600', dot: 'bg-blue-500' },
{ key: 'done', label: 'DONE', color: 'text-muted-foreground', dot: 'bg-muted-foreground' },
]
const AREAS = ['3D Printing', 'Electronics', 'Woodshop', 'Laser', 'Metal Shop', 'Facilities', 'Admin', 'Software', 'General']
export function ProjectsClient({ projects: initialProjects, spaceId }: { projects: Project[], spaceId: string }) {
const [projects, setProjects] = useState<Project[]>(initialProjects)
const [showCreate, setShowCreate] = useState(false)
const [form, setForm] = useState({ title: '', description: '', area: '', tags: '', due_date: '' })
const [formLoading, setFormLoading] = useState(false)
const [formError, setFormError] = useState('')
const columns: Record<string, Project[]> = {}
STATUS_COLS.forEach(col => { columns[col.key] = projects.filter(p => p.status === col.key) })
async function handleCreate(e: React.FormEvent) {
e.preventDefault()
setFormLoading(true)
setFormError('')
const result = await createProject({
title: form.title,
description: form.description,
area: form.area,
tags: form.tags ? form.tags.split(',').map(t => t.trim()).filter(Boolean) : [],
due_date: form.due_date,
})
if (result.error) { setFormError(result.error); setFormLoading(false); return }
if (result.data) setProjects(prev => [result.data as Project, ...prev])
setShowCreate(false)
setForm({ title: '', description: '', area: '', tags: '', due_date: '' })
setFormLoading(false)
}
async function handleStatusChange(projectId: string, newStatus: string) {
const result = await updateProjectStatus(projectId, newStatus)
if (!result.error) {
setProjects(prev => prev.map(p => p.id === projectId ? { ...p, status: newStatus } : p))
}
}
async function handleDelete(projectId: string) {
if (!confirm('Delete this project?')) return
const result = await deleteProject(projectId)
if (!result.error) setProjects(prev => prev.filter(p => p.id !== projectId))
}
return (
<div className="min-h-screen bg-background">
<div className="bg-sidebar px-6 py-3 flex items-center justify-between">
<h1 className="text-white font-sans text-lg font-semibold">Projects</h1>
<button
onClick={() => setShowCreate(true)}
className="flex items-center gap-1.5 bg-primary text-white text-xs font-sans px-3 py-1.5 rounded hover:bg-primary/90 transition"
>
<Plus className="w-3.5 h-3.5" /> New Project
</button>
</div>
<div className="p-6">
<div className="grid grid-cols-4 gap-4">
{STATUS_COLS.map(col => {
const items = columns[col.key] ?? []
return (
<div key={col.key} className="flex flex-col gap-3">
<div className="flex items-center gap-2">
<span className={`w-2 h-2 rounded-full ${col.dot}`} />
<p className={`font-mono text-[10px] tracking-widest ${col.color}`}>{col.label}</p>
<span className="font-mono text-[10px] text-muted-foreground ml-auto">{items.length}</span>
</div>
{items.map(project => (
<div key={project.id} className="bg-card rounded border border-border p-4 hover:border-primary/30 transition group">
<div className="flex items-start justify-between mb-1 gap-2">
<p className="font-sans text-sm font-medium text-foreground leading-tight">{project.title}</p>
<button
onClick={() => handleDelete(project.id)}
className="opacity-0 group-hover:opacity-100 text-muted-foreground hover:text-destructive transition flex-shrink-0"
aria-label="Delete project"
>
<X className="w-3 h-3" />
</button>
</div>
{project.description && (
<p className="font-sans text-xs text-muted-foreground mb-2 line-clamp-2">{project.description}</p>
)}
<p className="font-mono text-[10px] text-muted-foreground mb-3">
{[project.area, project.due_date ? `due ${new Date(project.due_date).toLocaleDateString('en-US', { month: 'short', day: 'numeric' })}` : null].filter(Boolean).join(' · ')}
</p>
{project.progress !== undefined && project.progress !== null && (
<div className="h-1 bg-border rounded-full overflow-hidden mb-3">
<div className="h-full bg-primary rounded-full" style={{ width: `${project.progress}%` }} />
</div>
)}
<div className="flex flex-wrap gap-1 mb-3">
{project.tags?.map((tag: string) => (
<span key={tag} className="font-mono text-[10px] px-1.5 py-0.5 border border-border rounded text-muted-foreground">
{tag.toUpperCase()}
</span>
))}
</div>
{/* Status change */}
<select
value={project.status}
onChange={e => handleStatusChange(project.id, e.target.value)}
className="w-full bg-muted border border-border rounded px-2 py-1 font-mono text-[10px] text-foreground focus:outline-none focus:border-primary cursor-pointer"
>
{STATUS_COLS.map(s => <option key={s.key} value={s.key}>{s.label}</option>)}
</select>
</div>
))}
<button
onClick={() => setShowCreate(true)}
className="border-2 border-dashed border-border rounded p-3 text-center hover:border-primary/40 transition group"
>
<p className="font-sans text-xs text-muted-foreground group-hover:text-primary transition">+ Add project</p>
</button>
</div>
)
})}
</div>
</div>
{showCreate && (
<div className="fixed inset-0 bg-black/60 flex items-center justify-center z-50 p-4">
<div className="bg-card border border-border rounded-lg w-full max-w-lg">
<div className="flex items-center justify-between px-6 py-4 border-b border-border">
<h2 className="font-sans text-base font-semibold text-foreground">New Project</h2>
<button onClick={() => setShowCreate(false)} className="text-muted-foreground hover:text-foreground"><X className="w-4 h-4" /></button>
</div>
<form onSubmit={handleCreate} className="p-6 space-y-4">
<div>
<label className="font-mono text-[10px] tracking-widest text-muted-foreground uppercase block mb-1">Title *</label>
<input type="text" required autoFocus value={form.title} onChange={e => setForm(f => ({ ...f, title: e.target.value }))}
placeholder="CNC Controller Upgrade"
className="w-full bg-background border border-border rounded px-3 py-2 font-sans text-sm text-foreground placeholder:text-muted-foreground focus:outline-none focus:border-primary transition" />
</div>
<div>
<label className="font-mono text-[10px] tracking-widest text-muted-foreground uppercase block mb-1">Description</label>
<textarea value={form.description} onChange={e => setForm(f => ({ ...f, description: e.target.value }))}
rows={2} className="w-full bg-background border border-border rounded px-3 py-2 font-sans text-sm text-foreground placeholder:text-muted-foreground focus:outline-none focus:border-primary transition resize-none" />
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="font-mono text-[10px] tracking-widest text-muted-foreground uppercase block mb-1">Area</label>
<select value={form.area} onChange={e => setForm(f => ({ ...f, area: e.target.value }))}
className="w-full bg-background border border-border rounded px-3 py-2 font-sans text-sm text-foreground focus:outline-none focus:border-primary">
<option value="">No area</option>
{AREAS.map(a => <option key={a}>{a}</option>)}
</select>
</div>
<div>
<label className="font-mono text-[10px] tracking-widest text-muted-foreground uppercase block mb-1">Due Date</label>
<input type="date" value={form.due_date} onChange={e => setForm(f => ({ ...f, due_date: e.target.value }))}
className="w-full bg-background border border-border rounded px-3 py-2 font-sans text-sm text-foreground focus:outline-none focus:border-primary" />
</div>
</div>
<div>
<label className="font-mono text-[10px] tracking-widest text-muted-foreground uppercase block mb-1">Tags (comma-separated)</label>
<input type="text" value={form.tags} onChange={e => setForm(f => ({ ...f, tags: e.target.value }))}
placeholder="electronics, hardware, cnc"
className="w-full bg-background border border-border rounded px-3 py-2 font-mono text-sm text-foreground placeholder:text-muted-foreground focus:outline-none focus:border-primary transition" />
</div>
{formError && <p className="font-mono text-xs text-red-500">{formError}</p>}
<div className="flex gap-3 pt-2">
<button type="button" onClick={() => setShowCreate(false)} className="flex-1 border border-border text-foreground font-sans text-sm py-2 rounded hover:border-primary/50 transition">Cancel</button>
<button type="submit" disabled={formLoading} className="flex-1 bg-primary text-white font-sans text-sm py-2 rounded hover:bg-primary/90 transition disabled:opacity-60">
{formLoading ? 'Creating...' : 'Create Project'}
</button>
</div>
</form>
</div>
</div>
)}
</div>
)
}
+31
View File
@@ -0,0 +1,31 @@
import { createClient } from '@/lib/supabase/server'
import SettingsClient from './settings-client'
import { redirect } from 'next/navigation'
export default async function SettingsPage() {
const supabase = await createClient()
const { data: { user } } = await supabase.auth.getUser()
if (!user) redirect('/login')
const { data: member } = await supabase
.from('space_members').select('space_id, role').eq('user_id', user.id).eq('status', 'current').single()
if (!member) redirect('/login')
const { data: space } = await supabase
.from('spaces').select('*').eq('id', member.space_id).single()
const { data: integrations } = await supabase
.from('integrations').select('*').eq('space_id', member.space_id)
const isAdmin = member?.role === 'admin'
return (
<SettingsClient
space={space}
isAdmin={isAdmin}
integrations={integrations ?? []}
currentRole={member.role}
/>
)
}
+562
View File
@@ -0,0 +1,562 @@
'use client'
import { useState } from 'react'
import { X, Check, Copy, Eye, EyeOff, RotateCcw } from 'lucide-react'
import { updateSpaceSettings, saveIntegration, disconnectIntegration, rotateWebhookSecret } from '@/lib/actions'
interface Space {
id: string
name: string
slug: string
city?: string
invite_code?: string
require_approval?: boolean
public_member_directory?: boolean
webhook_secret?: string
}
interface Integration {
id: string
platform: string
name?: string
is_connected: boolean
config?: Record<string, string>
}
interface Props {
space: Space
isAdmin: boolean
integrations: Integration[]
currentRole: string
}
const ROLES_INFO = [
{ name: 'Admin', color: 'text-primary', description: 'Full access. Secrets, roles, integrations, all data.', permissions: ['ALL ACCESS'] },
{ name: 'Board', color: 'text-primary', description: 'Members, payments, projects, board secrets.', permissions: ['MEMBERS', 'PAYMENTS', 'PROJECTS'] },
{ name: 'Treasurer', color: 'text-primary', description: 'Payments, financial secrets, member payment status.', permissions: [] },
{ name: 'Member', color: 'text-foreground', description: 'Tasks, chores, projects, public KB, comms.', permissions: [] },
{ name: 'Associate', color: 'text-foreground', description: 'Read-only most areas. Can claim chores.', permissions: [] },
]
const INTEGRATIONS_CONFIG = [
{
platform: 'paypal',
name: 'PayPal',
description: 'Sync transactions for member dues',
icon: (
<svg className="w-6 h-6 text-blue-600" viewBox="0 0 24 24" fill="currentColor">
<path d="M7.5 2h9.3c2.7 0 4.5 1.9 4 4.5l-1.3 8.4c-.4 2.6-2.5 4.1-5.2 4.1H10l-.6 3H5.8L7.5 2zm2 2.2L7.6 17h3.8c1.6 0 2.8-.9 3-2.5l1.3-8.4c.2-1.2-.5-1.9-1.7-1.9H9.5z"/>
</svg>
),
fields: [
{ key: 'client_id', label: 'Client ID', type: 'text', placeholder: 'Sandbox or Live Client ID' },
{ key: 'client_secret', label: 'Client Secret', type: 'password', placeholder: 'Client Secret from PayPal Developer' },
{ key: 'mode', label: 'Mode', type: 'select', options: ['sandbox', 'live'] },
],
docs: 'https://developer.paypal.com/api/rest/',
},
{
platform: 'zeffy',
name: 'Zeffy',
description: 'Nonprofit payment and donation sync',
icon: (
<svg className="w-6 h-6 text-violet-600" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path strokeLinecap="round" strokeLinejoin="round" d="M4.5 12a7.5 7.5 0 0015 0m-15 0a7.5 7.5 0 1115 0m-15 0H3m16.5 0H21m-1.5 0H12m-8.457 3.077l1.41-.513m14.095-5.13l1.41-.512M5.106 17.785l1.15-.964m11.49-9.642l1.149-.964M7.501 19.795l.75-1.3m7.5-12.99l.75-1.3m-6.063 16.658l.26-1.477m2.605-14.772l.26-1.477m0 17.726l-.26-1.477M10.698 4.614l-.26-1.477" />
</svg>
),
fields: [
{ key: 'api_key', label: 'API Key', type: 'password', placeholder: 'Zeffy API Key' },
{ key: 'organization_id', label: 'Organization ID', type: 'text', placeholder: 'Your Zeffy Org ID' },
],
docs: 'https://docs.zeffy.com/',
},
{
platform: 'venmo',
name: 'Venmo',
description: 'Pull Venmo business transactions',
icon: (
<svg className="w-6 h-6 text-green-600" viewBox="0 0 24 24" fill="currentColor">
<path d="M19.5 3c.7 1.3 1 2.6 1 4.1 0 4.3-3.7 9.8-6.7 13.9H7.1L4.5 3h5.3l1.4 11.4C12.5 12 14.1 8.7 14.1 6.3c0-1.3-.2-2.2-.6-2.9L19.5 3z"/>
</svg>
),
fields: [
{ key: 'access_token', label: 'Access Token', type: 'password', placeholder: 'Venmo API Access Token' },
],
docs: 'https://developer.venmo.com/',
},
{
platform: 'stripe',
name: 'Stripe',
description: 'Accept card payments and subscriptions',
icon: (
<svg className="w-6 h-6 text-indigo-600" viewBox="0 0 24 24" fill="currentColor">
<path d="M13.976 9.15c-2.172-.806-3.356-1.426-3.356-2.409 0-.831.683-1.305 1.901-1.305 2.227 0 4.515.858 6.09 1.631l.89-5.494C18.252.975 15.697 0 12.165 0 9.667 0 7.589.654 6.104 1.872 4.56 3.147 3.757 4.992 3.757 7.218c0 4.039 2.467 5.76 6.476 7.219 2.585.92 3.445 1.574 3.445 2.583 0 .98-.84 1.545-2.354 1.545-1.875 0-4.965-.921-6.99-2.109l-.9 5.555C5.175 22.99 8.385 24 11.714 24c2.641 0 4.843-.624 6.328-1.813 1.664-1.305 2.525-3.236 2.525-5.732 0-4.128-2.524-5.851-6.591-7.305z"/>
</svg>
),
fields: [
{ key: 'publishable_key', label: 'Publishable Key', type: 'text', placeholder: 'pk_test_...' },
{ key: 'secret_key', label: 'Secret Key', type: 'password', placeholder: 'sk_test_...' },
{ key: 'webhook_secret', label: 'Webhook Secret', type: 'password', placeholder: 'whsec_...' },
],
docs: 'https://stripe.com/docs/api',
},
]
export default function SettingsClient({ space, isAdmin, integrations, currentRole }: Props) {
const [activeTab, setActiveTab] = useState<'space' | 'roles' | 'integrations' | 'webhooks'>('space')
const [saving, setSaving] = useState(false)
const [message, setMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null)
// Space form
const [spaceForm, setSpaceForm] = useState({
name: space.name || '',
slug: space.slug || '',
city: space.city || '',
require_approval: space.require_approval ?? true,
public_member_directory: space.public_member_directory ?? false,
})
// Integration config modal
const [editingIntegration, setEditingIntegration] = useState<typeof INTEGRATIONS_CONFIG[0] | null>(null)
const [integrationForm, setIntegrationForm] = useState<Record<string, string>>({})
const [showSecrets, setShowSecrets] = useState<Record<string, boolean>>({})
// Webhook
const [webhookSecret, setWebhookSecret] = useState(space.webhook_secret || '')
const [copiedWebhook, setCopiedWebhook] = useState(false)
async function handleSaveSpace(e: React.FormEvent) {
e.preventDefault()
if (!isAdmin) return
setSaving(true)
setMessage(null)
const result = await updateSpaceSettings(spaceForm)
if (result.error) {
setMessage({ type: 'error', text: result.error })
} else {
setMessage({ type: 'success', text: 'Settings saved!' })
}
setSaving(false)
}
async function handleSaveIntegration(e: React.FormEvent) {
e.preventDefault()
if (!editingIntegration || !isAdmin) return
setSaving(true)
setMessage(null)
const result = await saveIntegration(editingIntegration.platform, integrationForm)
if (result.error) {
setMessage({ type: 'error', text: result.error })
} else {
setMessage({ type: 'success', text: `${editingIntegration.name} connected!` })
setEditingIntegration(null)
}
setSaving(false)
}
async function handleDisconnect(platform: string) {
if (!isAdmin) return
setSaving(true)
await disconnectIntegration(platform)
setSaving(false)
}
async function handleRotateWebhook() {
if (!isAdmin) return
setSaving(true)
const result = await rotateWebhookSecret()
if (result.secret) {
setWebhookSecret(result.secret)
setMessage({ type: 'success', text: 'Webhook secret rotated!' })
}
setSaving(false)
}
function copyToClipboard(text: string) {
navigator.clipboard.writeText(text)
setCopiedWebhook(true)
setTimeout(() => setCopiedWebhook(false), 2000)
}
function openIntegrationModal(config: typeof INTEGRATIONS_CONFIG[0]) {
const existing = integrations.find(i => i.platform === config.platform)
const initialForm: Record<string, string> = {}
config.fields.forEach(f => {
initialForm[f.key] = existing?.config?.[f.key] || ''
})
setIntegrationForm(initialForm)
setEditingIntegration(config)
}
const getIntegrationStatus = (platform: string) => integrations.find(i => i.platform === platform)?.is_connected ?? false
return (
<div className="min-h-screen bg-background">
{/* Header */}
<div className="bg-sidebar px-6 py-3 flex items-center">
<h1 className="text-white font-sans text-lg font-semibold">Settings & Admin</h1>
</div>
<div className="flex">
{/* Main content */}
<div className="flex-1 p-6">
{/* Tabs */}
<div className="bg-card border-b border-border flex gap-6 px-2 mb-6 rounded-t">
{(['space', 'roles', 'integrations', 'webhooks'] as const).map(tab => (
<button
key={tab}
onClick={() => setActiveTab(tab)}
className={`font-sans text-sm py-3 border-b-2 transition capitalize ${
activeTab === tab ? 'border-primary text-primary' : 'border-transparent text-muted-foreground hover:text-foreground'
}`}
>
{tab === 'roles' ? 'Users & Roles' : tab.charAt(0).toUpperCase() + tab.slice(1)}
</button>
))}
</div>
{message && (
<div className={`mb-4 px-4 py-2 rounded text-sm font-sans ${
message.type === 'success' ? 'bg-green-50 text-green-700 border border-green-200' : 'bg-red-50 text-red-700 border border-red-200'
}`}>
{message.text}
</div>
)}
{/* Space Settings Tab */}
{activeTab === 'space' && (
<div className="bg-card rounded border border-border p-6">
<p className="font-mono text-[10px] tracking-widest text-muted-foreground uppercase mb-6">Space Settings</p>
<form onSubmit={handleSaveSpace} className="space-y-6">
<div className="grid grid-cols-2 gap-6">
<div>
<label className="font-sans text-sm text-foreground block mb-1.5">Space Name</label>
<input
type="text"
value={spaceForm.name}
onChange={e => setSpaceForm(f => ({ ...f, name: e.target.value }))}
disabled={!isAdmin}
className="w-full bg-background border border-border rounded px-3 py-2 font-sans text-sm text-foreground disabled:opacity-50 focus:outline-none focus:border-primary"
/>
</div>
<div>
<label className="font-sans text-sm text-foreground block mb-1.5">URL Slug</label>
<div className="flex items-center">
<span className="font-mono text-xs text-muted-foreground mr-2">hackerspace.sh/</span>
<input
type="text"
value={spaceForm.slug}
onChange={e => setSpaceForm(f => ({ ...f, slug: e.target.value.toLowerCase().replace(/[^a-z0-9-]/g, '') }))}
disabled={!isAdmin}
className="flex-1 bg-background border border-border rounded px-3 py-2 font-mono text-sm text-foreground disabled:opacity-50 focus:outline-none focus:border-primary"
/>
</div>
</div>
</div>
<div>
<label className="font-sans text-sm text-foreground block mb-1.5">City / Location (optional)</label>
<input
type="text"
value={spaceForm.city}
onChange={e => setSpaceForm(f => ({ ...f, city: e.target.value }))}
placeholder="Mesa, AZ"
disabled={!isAdmin}
className="w-full max-w-md bg-background border border-border rounded px-3 py-2 font-sans text-sm text-foreground placeholder:text-muted-foreground disabled:opacity-50 focus:outline-none focus:border-primary"
/>
</div>
<div className="space-y-4 pt-2">
<div className="flex items-center justify-between py-3 border-b border-border">
<div>
<p className="font-sans text-sm text-foreground">Require approval to join</p>
<p className="font-sans text-xs text-muted-foreground">New members must be approved by an admin</p>
</div>
<button
type="button"
onClick={() => isAdmin && setSpaceForm(f => ({ ...f, require_approval: !f.require_approval }))}
disabled={!isAdmin}
className={`w-11 h-6 rounded-full transition relative ${spaceForm.require_approval ? 'bg-primary' : 'bg-muted'}`}
>
<span className={`absolute top-1 w-4 h-4 rounded-full bg-white transition-all ${spaceForm.require_approval ? 'left-6' : 'left-1'}`} />
</button>
</div>
<div className="flex items-center justify-between py-3">
<div>
<p className="font-sans text-sm text-foreground">Public member directory</p>
<p className="font-sans text-xs text-muted-foreground">Anyone can see member list (names only)</p>
</div>
<button
type="button"
onClick={() => isAdmin && setSpaceForm(f => ({ ...f, public_member_directory: !f.public_member_directory }))}
disabled={!isAdmin}
className={`w-11 h-6 rounded-full transition relative ${spaceForm.public_member_directory ? 'bg-primary' : 'bg-muted'}`}
>
<span className={`absolute top-1 w-4 h-4 rounded-full bg-white transition-all ${spaceForm.public_member_directory ? 'left-6' : 'left-1'}`} />
</button>
</div>
</div>
<div className="pt-4 border-t border-border">
<p className="font-sans text-sm text-foreground mb-1.5">Invite code</p>
<div className="flex items-center gap-2">
<code className="bg-primary/5 border border-primary/20 text-primary font-mono text-sm px-3 py-2 rounded">
{space.invite_code || 'No code set'}
</code>
{space.invite_code && (
<button
type="button"
onClick={() => copyToClipboard(space.invite_code!)}
className="text-muted-foreground hover:text-primary transition p-2"
>
<Copy className="w-4 h-4" />
</button>
)}
</div>
</div>
{isAdmin && (
<div className="pt-4">
<button
type="submit"
disabled={saving}
className="bg-primary text-white font-sans text-sm px-6 py-2 rounded hover:bg-primary/90 transition disabled:opacity-50"
>
{saving ? 'Saving...' : 'Save Settings'}
</button>
</div>
)}
</form>
</div>
)}
{/* Users & Roles Tab */}
{activeTab === 'roles' && (
<div className="bg-card rounded border border-border p-6">
<p className="font-mono text-[10px] tracking-widest text-muted-foreground uppercase mb-6">Roles & Permissions</p>
<div className="space-y-4">
{ROLES_INFO.map(role => (
<div key={role.name} className="flex items-start gap-4 py-3 border-b border-border last:border-0">
<div className="flex-1">
<p className={`font-sans text-sm font-medium ${role.color}`}>{role.name}</p>
<p className="font-sans text-xs text-muted-foreground mt-0.5">{role.description}</p>
</div>
{role.permissions.length > 0 && (
<div className="flex gap-1.5">
{role.permissions.map(p => (
<span key={p} className="font-mono text-[10px] px-2 py-0.5 rounded bg-primary/10 text-primary border border-primary/20">
{p}
</span>
))}
</div>
)}
</div>
))}
</div>
</div>
)}
{/* Integrations Tab */}
{activeTab === 'integrations' && (
<div className="bg-card rounded border border-border p-6">
<p className="font-mono text-[10px] tracking-widest text-muted-foreground uppercase mb-6">Integrations</p>
<div className="space-y-3">
{INTEGRATIONS_CONFIG.map(config => {
const connected = getIntegrationStatus(config.platform)
return (
<div key={config.platform} className="flex items-center gap-4 p-4 border border-border rounded hover:border-primary/30 transition">
<div className="w-8 h-8 flex items-center justify-center flex-shrink-0">{config.icon}</div>
<div className="flex-1">
<p className="font-sans text-sm font-medium text-foreground">{config.name}</p>
<p className="font-sans text-xs text-muted-foreground">{config.description}</p>
</div>
<div className="flex items-center gap-2">
{connected ? (
<>
<span className="flex items-center gap-1 font-mono text-[10px] text-primary">
<Check className="w-3 h-3" /> Connected
</span>
{isAdmin && (
<button
onClick={() => openIntegrationModal(config)}
className="font-mono text-[10px] border border-border px-2 py-1 rounded hover:border-primary hover:text-primary transition"
>
Manage
</button>
)}
</>
) : isAdmin ? (
<button
onClick={() => openIntegrationModal(config)}
className="font-mono text-[10px] bg-primary text-white px-3 py-1 rounded hover:bg-primary/90 transition"
>
Connect
</button>
) : (
<span className="font-mono text-[10px] text-muted-foreground">Not connected</span>
)}
</div>
</div>
)
})}
</div>
</div>
)}
{/* Webhooks Tab */}
{activeTab === 'webhooks' && (
<div className="bg-card rounded border border-border p-6">
<p className="font-mono text-[10px] tracking-widest text-muted-foreground uppercase mb-6">Webhook Endpoint</p>
<p className="font-sans text-sm text-muted-foreground mb-4">
POST members to this endpoint for external integration or automation.
</p>
<div className="bg-muted/50 border border-border rounded p-4 mb-4">
<code className="font-mono text-xs text-primary break-all">
https://api.hackerspace.sh/{space.slug}/members
</code>
</div>
<div className="flex items-center gap-2">
<button
onClick={() => copyToClipboard(`https://api.hackerspace.sh/${space.slug}/members`)}
className="font-mono text-[10px] border border-border px-3 py-1.5 rounded hover:border-primary hover:text-primary transition flex items-center gap-1"
>
<Copy className="w-3 h-3" />
{copiedWebhook ? 'Copied!' : 'Copy'}
</button>
<a
href="https://docs.hackerspace.sh/webhooks"
target="_blank"
rel="noopener noreferrer"
className="font-mono text-[10px] border border-border px-3 py-1.5 rounded hover:border-primary hover:text-primary transition"
>
View Docs
</a>
{isAdmin && (
<button
onClick={handleRotateWebhook}
disabled={saving}
className="font-mono text-[10px] border border-border px-3 py-1.5 rounded hover:border-primary hover:text-primary transition flex items-center gap-1"
>
<RotateCcw className="w-3 h-3" />
Rotate Secret
</button>
)}
</div>
</div>
)}
</div>
{/* Right sidebar - Roles panel (visible on roles tab) */}
{activeTab === 'roles' && (
<div className="w-80 border-l border-border p-6 bg-card/50">
<p className="font-mono text-[10px] tracking-widest text-muted-foreground uppercase mb-4">Role Assignment</p>
<p className="font-sans text-sm text-muted-foreground">
Member roles can be changed from the Members page. Only admins can change roles.
</p>
</div>
)}
</div>
{/* Integration Config Modal */}
{editingIntegration && (
<div className="fixed inset-0 bg-black/60 flex items-center justify-center z-50 p-4">
<div className="bg-card border border-border rounded-lg w-full max-w-md">
<div className="flex items-center justify-between px-6 py-4 border-b border-border">
<div className="flex items-center gap-2">
<div className="w-6 h-6 flex items-center justify-center">{editingIntegration.icon}</div>
<h2 className="font-sans text-base font-semibold text-foreground">{editingIntegration.name}</h2>
</div>
<button onClick={() => setEditingIntegration(null)} className="text-muted-foreground hover:text-foreground">
<X className="w-4 h-4" />
</button>
</div>
<form onSubmit={handleSaveIntegration} className="p-6 space-y-4">
{editingIntegration.fields.map(field => (
<div key={field.key}>
<label className="font-mono text-[10px] tracking-widest text-muted-foreground uppercase block mb-1">{field.label}</label>
{field.type === 'select' ? (
<select
value={integrationForm[field.key] || ''}
onChange={e => setIntegrationForm(f => ({ ...f, [field.key]: e.target.value }))}
className="w-full bg-background border border-border rounded px-3 py-2 font-sans text-sm text-foreground focus:outline-none focus:border-primary"
>
<option value="">Select...</option>
{field.options?.map(opt => <option key={opt} value={opt}>{opt}</option>)}
</select>
) : (
<div className="relative">
<input
type={field.type === 'password' && !showSecrets[field.key] ? 'password' : 'text'}
value={integrationForm[field.key] || ''}
onChange={e => setIntegrationForm(f => ({ ...f, [field.key]: e.target.value }))}
placeholder={field.placeholder}
className="w-full bg-background border border-border rounded px-3 py-2 pr-10 font-mono text-sm text-foreground placeholder:text-muted-foreground focus:outline-none focus:border-primary"
/>
{field.type === 'password' && (
<button
type="button"
onClick={() => setShowSecrets(s => ({ ...s, [field.key]: !s[field.key] }))}
className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
>
{showSecrets[field.key] ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
</button>
)}
</div>
)}
</div>
))}
<div className="flex items-center justify-between pt-2">
<a
href={editingIntegration.docs}
target="_blank"
rel="noopener noreferrer"
className="font-mono text-[10px] text-primary hover:underline"
>
View API Docs →
</a>
</div>
<div className="flex gap-3 pt-2">
{getIntegrationStatus(editingIntegration.platform) && (
<button
type="button"
onClick={() => {
handleDisconnect(editingIntegration.platform)
setEditingIntegration(null)
}}
className="flex-1 border border-red-200 text-red-600 font-sans text-sm py-2 rounded hover:bg-red-50 transition"
>
Disconnect
</button>
)}
<button
type="button"
onClick={() => setEditingIntegration(null)}
className="flex-1 border border-border text-foreground font-sans text-sm py-2 rounded hover:border-primary/50 transition"
>
Cancel
</button>
<button
type="submit"
disabled={saving}
className="flex-1 bg-primary text-white font-sans text-sm py-2 rounded hover:bg-primary/90 transition disabled:opacity-50"
>
{saving ? 'Saving...' : 'Save'}
</button>
</div>
</form>
</div>
</div>
)}
</div>
)
}
+25
View File
@@ -0,0 +1,25 @@
import { createClient } from '@/lib/supabase/server'
import { TasksClient } from './tasks-client'
export default async function TasksPage() {
const supabase = await createClient()
const { data: { user } } = await supabase.auth.getUser()
if (!user) return null
const { data: member } = await supabase
.from('space_members')
.select('space_id, user_id, display_name, role')
.eq('user_id', user.id)
.in('status', ['current', 'unverified', 'late'])
.single()
const spaceId = member?.space_id
if (!spaceId) return null
const [{ data: tasks }, { data: members }] = await Promise.all([
supabase.from('tasks').select('*').eq('space_id', spaceId).order('created_at', { ascending: false }),
supabase.from('space_members').select('id, display_name, user_id').eq('space_id', spaceId).in('status', ['current', 'late']),
])
return <TasksClient tasks={tasks ?? []} members={members ?? []} currentUserId={user.id} spaceId={spaceId} />
}
+355
View File
@@ -0,0 +1,355 @@
'use client'
import { useState, useTransition } from 'react'
import { Plus, X, CheckCircle2 } from 'lucide-react'
import { createTask, claimTask, completeTask, deleteTask } from '@/lib/actions'
interface Task {
id: string
title: string
description?: string
type?: string
task_type?: string
status: string
area?: string
recurrence?: string
due_date?: string
claimed_by?: string
claimed_by_name?: string
assigned_to?: string
assigned_to_name?: string
progress?: number
subtask_completed?: number
subtask_total?: number
created_at: string
}
interface Props {
tasks: Task[]
members: { id: string; display_name: string; user_id: string }[]
currentUserId: string
spaceId: string
}
const AREAS = ['3D Printing', 'Electronics', 'Woodshop', 'Laser', 'Metal Shop', 'Facilities', 'Admin', 'Kitchen', 'General']
const RECURRENCES = ['none', 'daily', 'weekly', 'biweekly', 'monthly']
export function TasksClient({ tasks: initialTasks, members, currentUserId, spaceId }: Props) {
const [tasks, setTasks] = useState<Task[]>(initialTasks)
const [activeTab, setActiveTab] = useState<'chores' | 'ongoing' | 'mine' | 'done'>('chores')
const [filterArea, setFilterArea] = useState('')
const [showCreate, setShowCreate] = useState(false)
const [isPending, startTransition] = useTransition()
// Create form state
const [form, setForm] = useState({
title: '',
description: '',
type: 'task',
area: '',
recurrence: 'none',
due_date: '',
})
const [formError, setFormError] = useState('')
const [formLoading, setFormLoading] = useState(false)
// Ongoing = tasks with a recurrence (not 'none')
const chores = tasks.filter(t => (t.type === 'chore' || t.task_type === 'chore') && t.status !== 'done' && (!t.recurrence || t.recurrence === 'none'))
const ongoing = tasks.filter(t => t.recurrence && t.recurrence !== 'none' && t.status !== 'done')
const mine = tasks.filter(t => (t.claimed_by === currentUserId || t.assigned_to === currentUserId) && t.status !== 'done')
const done = tasks.filter(t => t.status === 'done')
const filtered = (list: Task[]) => filterArea ? list.filter(t => t.area === filterArea) : list
const tabData = { chores: filtered(chores), ongoing: filtered(ongoing), mine: filtered(mine), done: filtered(done) }
const currentList = tabData[activeTab]
async function handleCreate(e: React.FormEvent) {
e.preventDefault()
if (!form.title.trim()) return
setFormLoading(true)
setFormError('')
const result = await createTask(form)
if (result.error) {
setFormError(result.error)
setFormLoading(false)
return
}
if (result.data) {
setTasks(prev => [result.data as Task, ...prev])
}
setShowCreate(false)
setForm({ title: '', description: '', type: 'task', area: '', recurrence: 'none', due_date: '' })
setFormLoading(false)
}
async function handleClaim(taskId: string) {
const result = await claimTask(taskId)
if (!result.error) {
const member = members.find(m => m.user_id === currentUserId)
setTasks(prev => prev.map(t =>
t.id === taskId
? { ...t, status: 'claimed', claimed_by: currentUserId, claimed_by_name: member?.display_name }
: t
))
}
}
async function handleComplete(taskId: string) {
const result = await completeTask(taskId)
if (!result.error) {
setTasks(prev => prev.map(t => t.id === taskId ? { ...t, status: 'done' } : t))
}
}
async function handleDelete(taskId: string) {
const result = await deleteTask(taskId)
if (!result.error) {
setTasks(prev => prev.filter(t => t.id !== taskId))
}
}
const tabCounts = {
chores: filtered(chores).length,
ongoing: filtered(ongoing).length,
mine: filtered(mine).length,
done: filtered(done).length,
}
return (
<div className="min-h-screen bg-background">
<div className="bg-sidebar px-6 py-3 flex items-center justify-between">
<div className="flex items-center gap-3">
<h1 className="text-white font-sans text-lg font-semibold">Tasks & Chores</h1>
<span className="font-mono text-xs text-white/50">{tasks.filter(t => t.status !== 'done').length} open</span>
</div>
<div className="flex items-center gap-2">
<select
value={filterArea}
onChange={e => setFilterArea(e.target.value)}
className="bg-sidebar-accent border border-sidebar-border text-sidebar-foreground text-xs font-sans rounded px-2 py-1.5"
>
<option value="">All Areas</option>
{AREAS.map(a => <option key={a}>{a}</option>)}
</select>
<button
onClick={() => setShowCreate(true)}
className="flex items-center gap-1.5 bg-primary text-white text-xs font-sans px-3 py-1.5 rounded hover:bg-primary/90 transition"
>
<Plus className="w-3.5 h-3.5" /> New Task
</button>
</div>
</div>
<div className="bg-card border-b border-border px-6 flex gap-6">
{(['chores', 'ongoing', 'mine', 'done'] as const).map((tab) => (
<button
key={tab}
onClick={() => setActiveTab(tab)}
className={`font-sans text-sm py-3 border-b-2 transition capitalize ${
activeTab === tab ? 'border-primary text-primary' : 'border-transparent text-muted-foreground hover:text-foreground'
}`}
>
{tab === 'mine' ? 'My Tasks' : tab.charAt(0).toUpperCase() + tab.slice(1)}{' '}
{tabCounts[tab] > 0 && (
<span className={`ml-1 text-[10px] font-mono px-1.5 py-0.5 rounded ${
activeTab === tab ? 'bg-primary/10 text-primary' : 'bg-muted text-muted-foreground'
}`}>
{tabCounts[tab]}
</span>
)}
</button>
))}
</div>
<div className="p-6">
<div className="bg-card rounded border border-border divide-y divide-border">
{currentList.length > 0 ? currentList.map(task => (
<div key={task.id} className={`flex items-center gap-3 px-4 py-3 ${
task.status === 'done' ? 'opacity-60' : ''
}`}>
<div className={`w-4 h-4 rounded flex-shrink-0 flex items-center justify-center ${
task.status === 'done' ? 'bg-primary' : 'border-2 border-border'
}`}>
{task.status === 'done' && (
<svg className="w-2.5 h-2.5 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={3} d="M5 13l4 4L19 7" />
</svg>
)}
</div>
<div className="flex-1 min-w-0">
<p className={`font-sans text-sm ${task.status === 'done' ? 'line-through text-muted-foreground' : 'text-foreground'}`}>
{task.title}
</p>
<p className="font-mono text-[10px] text-muted-foreground mt-0.5">
{[
task.area,
task.recurrence && task.recurrence !== 'none' ? `${task.recurrence} recurring` : null,
task.claimed_by_name ? `claimed by ${task.claimed_by_name}` : null,
task.assigned_to_name ? `assigned to ${task.assigned_to_name}` : null,
task.due_date ? `due ${new Date(task.due_date).toLocaleDateString('en-US', { month: 'short', day: 'numeric' })}` : null,
].filter(Boolean).join(' · ')}
</p>
</div>
<span className={`font-mono text-[10px] px-2 py-0.5 rounded whitespace-nowrap flex-shrink-0 ${
task.status === 'claimed' ? 'text-primary bg-primary/10' :
task.status === 'in_progress' ? 'text-blue-600 bg-blue-50' :
task.status === 'blocked' ? 'text-orange-600 bg-orange-50' :
task.status === 'done' ? 'text-muted-foreground bg-muted' :
'text-muted-foreground bg-muted'
}`}>
{task.status.toUpperCase().replace('_', ' ')}
</span>
{task.status === 'open' && (
<button
onClick={() => handleClaim(task.id)}
className="font-mono text-[10px] border border-border px-2 py-0.5 rounded hover:border-primary hover:text-primary transition whitespace-nowrap"
>
CLAIM
</button>
)}
{(task.status === 'claimed' || task.status === 'in_progress') && task.claimed_by === currentUserId && (
<button
onClick={() => handleComplete(task.id)}
className="font-mono text-[10px] border border-green-200 text-green-700 px-2 py-0.5 rounded hover:bg-green-50 transition whitespace-nowrap"
>
DONE
</button>
)}
<button
onClick={() => handleDelete(task.id)}
className="text-muted-foreground hover:text-destructive transition flex-shrink-0"
aria-label="Delete task"
>
<X className="w-3.5 h-3.5" />
</button>
</div>
)) : (
<div className="px-4 py-10 text-center">
<p className="font-sans text-sm text-muted-foreground">No {activeTab === 'done' ? 'completed' : 'open'} tasks in this view</p>
<button
onClick={() => setShowCreate(true)}
className="font-mono text-xs text-primary mt-2 hover:underline"
>
+ Add first task
</button>
</div>
)}
</div>
</div>
{/* Create Task Modal */}
{showCreate && (
<div className="fixed inset-0 bg-black/60 flex items-center justify-center z-50 p-4">
<div className="bg-card border border-border rounded-lg w-full max-w-lg">
<div className="flex items-center justify-between px-6 py-4 border-b border-border">
<h2 className="font-sans text-base font-semibold text-foreground">New Task</h2>
<button onClick={() => setShowCreate(false)} className="text-muted-foreground hover:text-foreground">
<X className="w-4 h-4" />
</button>
</div>
<form onSubmit={handleCreate} className="p-6 space-y-4">
<div>
<label className="font-mono text-[10px] tracking-widest text-muted-foreground uppercase block mb-1">Title *</label>
<input
type="text"
value={form.title}
onChange={e => setForm(f => ({ ...f, title: e.target.value }))}
placeholder="Clean the laser cutters"
required
autoFocus
className="w-full bg-background border border-border rounded px-3 py-2 font-sans text-sm text-foreground placeholder:text-muted-foreground focus:outline-none focus:border-primary transition"
/>
</div>
<div>
<label className="font-mono text-[10px] tracking-widest text-muted-foreground uppercase block mb-1">Description</label>
<textarea
value={form.description}
onChange={e => setForm(f => ({ ...f, description: e.target.value }))}
placeholder="Optional details..."
rows={2}
className="w-full bg-background border border-border rounded px-3 py-2 font-sans text-sm text-foreground placeholder:text-muted-foreground focus:outline-none focus:border-primary transition resize-none"
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="font-mono text-[10px] tracking-widest text-muted-foreground uppercase block mb-1">Type</label>
<select
value={form.type}
onChange={e => setForm(f => ({ ...f, type: e.target.value }))}
className="w-full bg-background border border-border rounded px-3 py-2 font-sans text-sm text-foreground focus:outline-none focus:border-primary"
>
<option value="task">Task</option>
<option value="chore">Chore</option>
</select>
</div>
<div>
<label className="font-mono text-[10px] tracking-widest text-muted-foreground uppercase block mb-1">Area</label>
<select
value={form.area}
onChange={e => setForm(f => ({ ...f, area: e.target.value }))}
className="w-full bg-background border border-border rounded px-3 py-2 font-sans text-sm text-foreground focus:outline-none focus:border-primary"
>
<option value="">No area</option>
{AREAS.map(a => <option key={a}>{a}</option>)}
</select>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="font-mono text-[10px] tracking-widest text-muted-foreground uppercase block mb-1">Recurrence</label>
<select
value={form.recurrence}
onChange={e => setForm(f => ({ ...f, recurrence: e.target.value }))}
className="w-full bg-background border border-border rounded px-3 py-2 font-sans text-sm text-foreground focus:outline-none focus:border-primary"
>
{RECURRENCES.map(r => <option key={r}>{r}</option>)}
</select>
</div>
<div>
<label className="font-mono text-[10px] tracking-widest text-muted-foreground uppercase block mb-1">Due Date</label>
<input
type="date"
value={form.due_date}
onChange={e => setForm(f => ({ ...f, due_date: e.target.value }))}
className="w-full bg-background border border-border rounded px-3 py-2 font-sans text-sm text-foreground focus:outline-none focus:border-primary"
/>
</div>
</div>
{formError && <p className="font-mono text-xs text-red-500">{formError}</p>}
<div className="flex gap-3 pt-2">
<button
type="button"
onClick={() => setShowCreate(false)}
className="flex-1 border border-border text-foreground font-sans text-sm py-2 rounded hover:border-primary/50 transition"
>
Cancel
</button>
<button
type="submit"
disabled={formLoading || !form.title.trim()}
className="flex-1 bg-primary text-white font-sans text-sm py-2 rounded hover:bg-primary/90 transition disabled:opacity-60"
>
{formLoading ? 'Creating...' : 'Create Task'}
</button>
</div>
</form>
</div>
</div>
)}
</div>
)
}
+18
View File
@@ -0,0 +1,18 @@
import { createClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server'
export async function GET(request: Request) {
const { searchParams, origin } = new URL(request.url)
const code = searchParams.get('code')
const next = searchParams.get('next') ?? '/dashboard'
if (code) {
const supabase = await createClient()
const { error } = await supabase.auth.exchangeCodeForSession(code)
if (!error) {
return NextResponse.redirect(`${origin}${next}`)
}
}
return NextResponse.redirect(`${origin}/login?error=auth_error`)
}
+150
View File
@@ -0,0 +1,150 @@
'use client'
import { useState } from 'react'
import { useRouter } from 'next/navigation'
import Link from 'next/link'
import { signIn } from '@/lib/auth-actions'
import { Button } from '@/components/ui/button'
import { Input} from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { createClient } from '@/lib/supabase/client'
export default function LoginPage() {
const router = useRouter()
const [email, setEmail] = useState('')
const [password, setPassword] = useState('')
const [error, setError] = useState('')
const [loading, setLoading] = useState(false)
async function handleEmailLogin(e: React.FormEvent) {
e.preventDefault()
setError('')
setLoading(true)
const result = await signIn(email, password)
if (result.error) {
setError(result.error)
setLoading(false)
} else {
router.push('/dashboard')
router.refresh()
}
}
async function handleOAuthLogin(provider: 'github' | 'google') {
const supabase = createClient()
const { error } = await supabase.auth.signInWithOAuth({
provider,
options: {
redirectTo: `${window.location.origin}/auth/callback`,
},
})
if (error) {
setError(error.message)
}
}
return (
<div className="min-h-screen bg-[var(--auth-bg)] flex items-center justify-center p-4">
<div className="w-full max-w-md">
<div className="border-t-2 border-[var(--auth-accent)] mb-12"></div>
<div className="mb-12">
<h1 className="text-4xl font-bold text-[var(--auth-accent)] font-mono mb-2">
{'{'}<span className="text-white">hackerspace.sh</span>{'}'}
</h1>
</div>
<div className="mb-8">
<h2 className="text-sm font-mono text-gray-400 uppercase tracking-wider mb-6">
Sign in to your space
</h2>
<form onSubmit={handleEmailLogin} className="space-y-4">
<div>
<Label htmlFor="email" className="text-sm text-gray-400 uppercase tracking-wide">Email</Label>
<Input
id="email"
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
className="mt-2 bg-black/40 border-gray-800 text-white"
required
/>
</div>
<div>
<Label htmlFor="password" className="text-sm text-gray-400 uppercase tracking-wide">Password</Label>
<Input
id="password"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
className="mt-2 bg-black/40 border-gray-800 text-white"
required
/>
</div>
{error && (
<div className="text-sm text-red-400 bg-red-950/30 border border-red-900/30 rounded px-3 py-2">
{error}
</div>
)}
<Button
type="submit"
disabled={loading}
className="w-full bg-[var(--auth-accent)] hover:bg-[var(--auth-accent)]/90 text-black font-semibold"
>
{loading ? 'Signing in...' : 'Sign in →'}
</Button>
</form>
<div className="mt-6 flex items-center gap-4">
<div className="flex-1 border-t border-gray-800"></div>
<span className="text-xs text-gray-600 uppercase">or</span>
<div className="flex-1 border-t border-gray-800"></div>
</div>
<div className="mt-6 space-y-3">
<Button
type="button"
variant="outline"
onClick={() => handleOAuthLogin('github')}
className="w-full border-gray-800 bg-transparent text-white hover:bg-gray-900"
>
<svg className="mr-2 h-4 w-4" fill="currentColor" viewBox="0 0 24 24">
<path d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z"/>
</svg>
GitHub
</Button>
<Button
type="button"
variant="outline"
onClick={() => handleOAuthLogin('google')}
className="w-full border-gray-800 bg-transparent text-white hover:bg-gray-900"
>
<svg className="mr-2 h-4 w-4" viewBox="0 0 24 24">
<path fill="#4285F4" d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z"/>
<path fill="#34A853" d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z"/>
<path fill="#FBBC05" d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z"/>
<path fill="#EA4335" d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z"/>
</svg>
Google
</Button>
</div>
<div className="mt-8 text-center">
<p className="text-sm text-gray-500">
no account?{' '}
<Link href="/auth/onboarding" className="text-[var(--auth-accent)] hover:underline">
create or join a space →
</Link>
</p>
</div>
</div>
</div>
</div>
)
}
+127
View File
@@ -0,0 +1,127 @@
@import 'tailwindcss';
@import 'tw-animate-css';
@import 'shadcn/tailwind.css';
@custom-variant dark (&:is(.dark *));
:root {
/* App bg: light grey */
--background: oklch(0.965 0 0);
--foreground: oklch(0.145 0 0);
--card: oklch(1 0 0);
--card-foreground: oklch(0.145 0 0);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.145 0 0);
/* Primary green */
--primary: oklch(0.46 0.145 150);
--primary-foreground: oklch(1 0 0);
--secondary: oklch(0.97 0 0);
--secondary-foreground: oklch(0.205 0 0);
--muted: oklch(0.97 0 0);
--muted-foreground: oklch(0.5 0 0);
--accent: oklch(0.95 0.02 150);
--accent-foreground: oklch(0.46 0.145 150);
--destructive: oklch(0.577 0.245 27.325);
--destructive-foreground: oklch(1 0 0);
--border: oklch(0.9 0 0);
--input: oklch(0.9 0 0);
--ring: oklch(0.46 0.145 150);
--chart-1: oklch(0.46 0.145 150);
--chart-2: oklch(0.6 0.118 184.704);
--chart-3: oklch(0.646 0.222 41.116);
--chart-4: oklch(0.828 0.189 84.429);
--chart-5: oklch(0.769 0.188 70.08);
--radius: 0.5rem;
/* Sidebar: dark charcoal like mockup */
--sidebar: oklch(0.165 0 0);
--sidebar-foreground: oklch(0.85 0 0);
--sidebar-primary: oklch(0.46 0.145 150);
--sidebar-primary-foreground: oklch(1 0 0);
--sidebar-accent: oklch(0.25 0 0);
--sidebar-accent-foreground: oklch(0.98 0 0);
--sidebar-border: oklch(0.24 0 0);
--sidebar-ring: oklch(0.46 0.145 150);
/* Auth page accent: neon lime-yellow */
--auth-accent: oklch(0.91 0.26 112);
--auth-bg: oklch(0.12 0 0);
}
.dark {
--background: oklch(0.165 0 0);
--foreground: oklch(0.985 0 0);
--card: oklch(0.2 0 0);
--card-foreground: oklch(0.985 0 0);
--popover: oklch(0.2 0 0);
--popover-foreground: oklch(0.985 0 0);
--primary: oklch(0.55 0.15 150);
--primary-foreground: oklch(0.985 0 0);
--secondary: oklch(0.27 0 0);
--secondary-foreground: oklch(0.985 0 0);
--muted: oklch(0.27 0 0);
--muted-foreground: oklch(0.65 0 0);
--accent: oklch(0.27 0 0);
--accent-foreground: oklch(0.985 0 0);
--destructive: oklch(0.396 0.141 25.723);
--destructive-foreground: oklch(0.637 0.237 25.331);
--border: oklch(0.27 0 0);
--input: oklch(0.27 0 0);
--ring: oklch(0.55 0.15 150);
--sidebar: oklch(0.165 0 0);
--sidebar-foreground: oklch(0.85 0 0);
--sidebar-primary: oklch(0.55 0.15 150);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.25 0 0);
--sidebar-accent-foreground: oklch(0.985 0 0);
--sidebar-border: oklch(0.24 0 0);
--sidebar-ring: oklch(0.55 0.15 150);
}
@theme inline {
--font-sans: 'Geist', 'Geist Fallback';
--font-mono: 'Geist Mono', 'Geist Mono Fallback';
--color-background: var(--background);
--color-foreground: var(--foreground);
--color-card: var(--card);
--color-card-foreground: var(--card-foreground);
--color-popover: var(--popover);
--color-popover-foreground: var(--popover-foreground);
--color-primary: var(--primary);
--color-primary-foreground: var(--primary-foreground);
--color-secondary: var(--secondary);
--color-secondary-foreground: var(--secondary-foreground);
--color-muted: var(--muted);
--color-muted-foreground: var(--muted-foreground);
--color-accent: var(--accent);
--color-accent-foreground: var(--accent-foreground);
--color-destructive: var(--destructive);
--color-destructive-foreground: var(--destructive-foreground);
--color-border: var(--border);
--color-input: var(--input);
--color-ring: var(--ring);
--color-chart-1: var(--chart-1);
--color-chart-2: var(--chart-2);
--color-chart-3: var(--chart-3);
--color-chart-4: var(--chart-4);
--color-chart-5: var(--chart-5);
--radius-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) + 4px);
--color-sidebar: var(--sidebar);
--color-sidebar-foreground: var(--sidebar-foreground);
--color-sidebar-primary: var(--sidebar-primary);
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
--color-sidebar-accent: var(--sidebar-accent);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-ring: var(--sidebar-ring);
}
@layer base {
* {
@apply border-border outline-ring/50;
}
body {
@apply bg-background text-foreground;
}
}
+45
View File
@@ -0,0 +1,45 @@
import type { Metadata } from 'next'
import { Geist, Geist_Mono } from 'next/font/google'
import { Analytics } from '@vercel/analytics/next'
import './globals.css'
const _geist = Geist({ subsets: ["latin"] });
const _geistMono = Geist_Mono({ subsets: ["latin"] });
export const metadata: Metadata = {
title: 'hackerspace.sh — Manage Your Space',
description: 'The operating system for hackerspaces: members, tasks, projects, ops, payments, and comms.',
generator: 'v0.app',
icons: {
icon: [
{
url: '/icon-light-32x32.png',
media: '(prefers-color-scheme: light)',
},
{
url: '/icon-dark-32x32.png',
media: '(prefers-color-scheme: dark)',
},
{
url: '/icon.svg',
type: 'image/svg+xml',
},
],
apple: '/apple-icon.png',
},
}
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode
}>) {
return (
<html lang="en">
<body className="font-sans antialiased">
{children}
<Analytics />
</body>
</html>
)
}
+132
View File
@@ -0,0 +1,132 @@
'use client'
import { useState } from 'react'
import { useRouter } from 'next/navigation'
import Link from 'next/link'
import { createClient } from '@/lib/supabase/client'
export default function LoginPage() {
const router = useRouter()
const [email, setEmail] = useState('')
const [password, setPassword] = useState('')
const [error, setError] = useState('')
const [loading, setLoading] = useState(false)
async function handleSubmit(e: React.FormEvent) {
e.preventDefault()
setLoading(true)
setError('')
const supabase = createClient()
const { error } = await supabase.auth.signInWithPassword({ email, password })
if (error) {
setError(error.message)
setLoading(false)
} else {
router.push('/dashboard')
router.refresh()
}
}
return (
<div className="min-h-screen bg-[#111] flex items-center justify-center relative overflow-hidden">
{/* Grid background */}
<div
className="absolute inset-0 opacity-20"
style={{
backgroundImage: `linear-gradient(oklch(0.4 0 0) 1px, transparent 1px), linear-gradient(90deg, oklch(0.4 0 0) 1px, transparent 1px)`,
backgroundSize: '48px 48px',
}}
/>
<div className="relative z-10 w-full max-w-[460px] mx-4">
{/* Yellow top border */}
<div className="h-1 w-full bg-[#d4f53c] rounded-t" />
<div className="bg-[#1a1a1a] rounded-b px-10 py-10">
{/* Logo */}
<div className="mb-8">
<span className="font-mono text-2xl font-bold">
<span className="text-[#d4f53c]">{'{'}</span>
<span className="text-white">hackerspace.sh</span>
<span className="text-[#d4f53c]">{'}'}</span>
</span>
</div>
<p className="font-mono text-[11px] tracking-widest text-zinc-500 mb-6 uppercase">
Sign in to your space
</p>
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label className="font-mono text-[10px] tracking-widest text-zinc-500 uppercase block mb-1">
Email
</label>
<input
type="email"
value={email}
onChange={e => setEmail(e.target.value)}
placeholder="moheeb@heatsyncabs.org"
required
className="w-full bg-[#232323] border border-[#333] text-white font-mono text-sm rounded px-3 py-2.5 placeholder:text-zinc-600 focus:outline-none focus:border-[#d4f53c] transition"
/>
</div>
<div>
<label className="font-mono text-[10px] tracking-widest text-zinc-500 uppercase block mb-1">
Password
</label>
<input
type="password"
value={password}
onChange={e => setPassword(e.target.value)}
placeholder="············"
required
className="w-full bg-[#232323] border border-[#333] text-white font-mono text-sm rounded px-3 py-2.5 placeholder:text-zinc-600 focus:outline-none focus:border-[#d4f53c] transition"
/>
</div>
{error && (
<p className="font-mono text-xs text-red-400">{error}</p>
)}
<button
type="submit"
disabled={loading}
className="w-full bg-[#d4f53c] text-black font-mono text-sm font-bold tracking-widest uppercase py-3 rounded hover:bg-[#c5e635] transition disabled:opacity-60 mt-2"
>
{loading ? 'Signing in...' : 'Sign In →'}
</button>
</form>
<div className="flex items-center gap-3 my-5">
<div className="flex-1 h-px bg-[#333]" />
<span className="font-mono text-xs text-zinc-600">OR</span>
<div className="flex-1 h-px bg-[#333]" />
</div>
<div className="flex gap-3">
<button className="flex-1 flex items-center justify-center gap-2 bg-[#232323] border border-[#333] text-white font-mono text-xs py-2.5 rounded hover:border-[#555] transition">
<svg className="w-4 h-4" viewBox="0 0 24 24" fill="currentColor">
<path d="M12 0C5.37 0 0 5.37 0 12c0 5.31 3.435 9.795 8.205 11.385.6.105.825-.255.825-.57 0-.285-.015-1.23-.015-2.235-3.015.555-3.795-.735-4.035-1.41-.135-.345-.72-1.41-1.23-1.695-.42-.225-1.02-.78-.015-.795.945-.015 1.62.87 1.845 1.23 1.08 1.815 2.805 1.305 3.495.99.105-.78.42-1.305.765-1.605-2.67-.3-5.46-1.335-5.46-5.925 0-1.305.465-2.385 1.23-3.225-.12-.3-.54-1.53.12-3.18 0 0 1.005-.315 3.3 1.23.96-.27 1.98-.405 3-.405s2.04.135 3 .405c2.295-1.56 3.3-1.23 3.3-1.23.66 1.65.24 2.88.12 3.18.765.84 1.23 1.905 1.23 3.225 0 4.605-2.805 5.625-5.475 5.925.435.375.81 1.095.81 2.22 0 1.605-.015 2.895-.015 3.3 0 .315.225.69.825.57A12.02 12.02 0 0024 12c0-6.63-5.37-12-12-12z" />
</svg>
GitHub
</button>
<button className="flex-1 flex items-center justify-center gap-2 bg-[#232323] border border-[#333] text-white font-mono text-xs py-2.5 rounded hover:border-[#555] transition">
<svg className="w-4 h-4" viewBox="0 0 24 24">
<path fill="#4285F4" d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z"/>
<path fill="#34A853" d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z"/>
<path fill="#FBBC05" d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z"/>
<path fill="#EA4335" d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z"/>
</svg>
Google
</button>
</div>
<p className="text-center font-mono text-xs text-zinc-600 mt-6">
no account?{' '}
<Link href="/signup" className="text-[#d4f53c] hover:underline">
create or join a space →
</Link>
</p>
</div>
</div>
</div>
)
}
+12
View File
@@ -0,0 +1,12 @@
import { createClient } from '@/lib/supabase/server'
import { redirect } from 'next/navigation'
export default async function RootPage() {
const supabase = await createClient()
const { data: { user } } = await supabase.auth.getUser()
if (user) {
redirect('/dashboard')
} else {
redirect('/login')
}
}
+64
View File
@@ -0,0 +1,64 @@
import Link from 'next/link'
export default async function ConfirmEmailPage({
searchParams,
}: {
searchParams: Promise<{ email?: string }>
}) {
const { email } = await searchParams
return (
<div className="min-h-screen bg-[#111] flex items-center justify-center relative overflow-hidden">
<div
className="absolute inset-0 opacity-20"
style={{
backgroundImage: `linear-gradient(oklch(0.4 0 0) 1px, transparent 1px), linear-gradient(90deg, oklch(0.4 0 0) 1px, transparent 1px)`,
backgroundSize: '48px 48px',
}}
/>
<div className="relative z-10 w-full max-w-[480px] mx-4">
<div className="h-1 w-full bg-[#d4f53c] rounded-t" />
<div className="bg-[#1a1a1a] rounded-b px-10 py-10 text-center">
<div className="mb-8">
<span className="font-mono text-2xl font-bold">
<span className="text-[#d4f53c]">{'{'}</span>
<span className="text-white">hackerspace.sh</span>
<span className="text-[#d4f53c]">{'}'}</span>
</span>
</div>
<div className="w-16 h-16 rounded-full bg-[#d4f53c]/10 border-2 border-[#d4f53c]/30 flex items-center justify-center mx-auto mb-6">
<svg className="w-8 h-8 text-[#d4f53c]" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" />
</svg>
</div>
<p className="font-mono text-[11px] tracking-widest text-zinc-500 mb-4 uppercase">
Check your email
</p>
<p className="font-sans text-white text-lg font-semibold mb-3">
Confirm your email address
</p>
<p className="font-sans text-sm text-zinc-400 leading-relaxed mb-2">
We sent a confirmation link to
</p>
{email && (
<p className="font-mono text-sm text-[#d4f53c] mb-6">{email}</p>
)}
<p className="font-sans text-xs text-zinc-600 mb-8 leading-relaxed">
Click the link in the email to activate your account and set up your space. It may take a minute to arrive. Check your spam folder if you don&apos;t see it.
</p>
<Link
href="/login"
className="font-mono text-xs text-[#d4f53c] hover:underline"
>
Already confirmed? Sign in →
</Link>
</div>
</div>
</div>
)
}
+387
View File
@@ -0,0 +1,387 @@
'use client'
import { useState, useEffect } from 'react'
import { useRouter } from 'next/navigation'
import Link from 'next/link'
import { createClient } from '@/lib/supabase/client'
import { createSpace, joinSpace } from '@/lib/auth-actions'
function generateInviteCode() {
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789'
const prefix = 'HSL'
const year = new Date().getFullYear()
let code = ''
for (let i = 0; i < 4; i++) {
code += chars.charAt(Math.floor(Math.random() * chars.length))
}
return `${prefix}-${year}-${code}`
}
export default function SignupPage() {
const router = useRouter()
const [step, setStep] = useState<'account' | 'space'>('account')
const [isReturningUser, setIsReturningUser] = useState(false)
const [mode, setMode] = useState<'create' | 'join' | null>(null)
const [fullName, setFullName] = useState('')
const [email, setEmail] = useState('')
const [password, setPassword] = useState('')
const [spaceName, setSpaceName] = useState('')
const [spaceSlug, setSpaceSlug] = useState('')
const [spaceCity, setSpaceCity] = useState('')
const [inviteCode, setInviteCode] = useState('')
const [error, setError] = useState('')
const [loading, setLoading] = useState(false)
useEffect(() => {
const checkAuth = async () => {
const supabase = createClient()
const { data: { user } } = await supabase.auth.getUser()
if (user) {
setEmail(user.email || '')
setFullName(user.user_metadata?.full_name || '')
setIsReturningUser(true)
setStep('space')
}
}
checkAuth()
}, [])
async function handleAccountSubmit(e: React.FormEvent) {
e.preventDefault()
if (!mode) { setError('Please select create or join a space'); return }
setError('')
setStep('space')
}
async function handleSpaceSubmit(e: React.FormEvent) {
e.preventDefault()
if (!mode) return
setLoading(true)
setError('')
// Returning user — call server actions (runs server-side, bypasses any client RLS issues)
if (isReturningUser) {
const result = mode === 'create'
? await createSpace({ spaceName, spaceSlug, spaceCity, displayName: fullName })
: await joinSpace({ inviteCode, displayName: fullName })
if (result.error) {
setError(result.error)
setLoading(false)
return
}
router.push('/dashboard')
router.refresh()
return
}
// New user — sign up; trigger will create space/member
const supabase = createClient()
if (mode === 'join') {
const { data: space, error: lookupErr } = await supabase
.from('spaces')
.select('id')
.eq('invite_code', inviteCode.trim().toUpperCase())
.single()
if (lookupErr || !space) { setError('Invalid invite code'); setLoading(false); return }
}
const metadata: Record<string, string> = {
full_name: fullName,
space_action: mode,
...(mode === 'create'
? { space_name: spaceName, space_slug: spaceSlug, space_city: spaceCity, invite_code: generateInviteCode() }
: { join_invite_code: inviteCode.trim().toUpperCase() }
),
}
const { data: authData, error: authError } = await supabase.auth.signUp({
email,
password,
options: {
emailRedirectTo: `${window.location.origin}/auth/callback`,
data: metadata,
},
})
if (authError) { setError(authError.message); setLoading(false); return }
if (authData.session) {
router.push('/dashboard')
router.refresh()
return
}
router.push('/signup/confirm?email=' + encodeURIComponent(email))
}
return (
<div className="min-h-screen bg-[#111] flex items-center justify-center relative overflow-hidden">
<div
className="absolute inset-0 opacity-20"
style={{
backgroundImage: `linear-gradient(oklch(0.4 0 0) 1px, transparent 1px), linear-gradient(90deg, oklch(0.4 0 0) 1px, transparent 1px)`,
backgroundSize: '48px 48px',
}}
/>
<div className="relative z-10 w-full max-w-[860px] mx-4">
<div className="h-1 w-full bg-[#d4f53c] rounded-t" />
<div className="bg-[#1a1a1a] rounded-b px-10 py-10">
<div className="mb-8">
<span className="font-mono text-2xl font-bold">
<span className="text-[#d4f53c]">{'{'}</span>
<span className="text-white">hackerspace.sh</span>
<span className="text-[#d4f53c]">{'}'}</span>
</span>
</div>
<p className="font-mono text-[11px] tracking-widest text-zinc-500 mb-6 uppercase">
{step === 'account' ? 'Create your account' : mode === 'create' ? 'Set up your Space' : mode === 'join' ? 'Join a Space' : 'Choose an option'}
{isReturningUser && step === 'space' && (
<span className="ml-2 normal-case text-zinc-600">({email})</span>
)}
</p>
{step === 'account' ? (
<form onSubmit={handleAccountSubmit}>
<div className="grid md:grid-cols-2 gap-4 mb-6">
<div>
<label className="font-mono text-[10px] tracking-widest text-zinc-500 uppercase block mb-1">
Full Name
</label>
<input
type="text"
value={fullName}
onChange={e => setFullName(e.target.value)}
placeholder="Moheeb Zara"
required
className="w-full bg-[#232323] border border-[#333] text-white font-mono text-sm rounded px-3 py-2.5 placeholder:text-zinc-600 focus:outline-none focus:border-[#d4f53c] transition"
/>
</div>
<div>
<label className="font-mono text-[10px] tracking-widest text-zinc-500 uppercase block mb-1">
Email
</label>
<input
type="email"
value={email}
onChange={e => setEmail(e.target.value)}
placeholder="you@space.org"
required
className="w-full bg-[#232323] border border-[#333] text-white font-mono text-sm rounded px-3 py-2.5 placeholder:text-zinc-600 focus:outline-none focus:border-[#d4f53c] transition"
/>
</div>
</div>
<div className="mb-8">
<label className="font-mono text-[10px] tracking-widest text-zinc-500 uppercase block mb-1">
Password
</label>
<input
type="password"
value={password}
onChange={e => setPassword(e.target.value)}
placeholder="min 8 characters"
required
minLength={8}
className="w-full bg-[#232323] border border-[#333] text-white font-mono text-sm rounded px-3 py-2.5 placeholder:text-zinc-600 focus:outline-none focus:border-[#d4f53c] transition"
/>
</div>
<p className="font-mono text-[10px] tracking-widest text-zinc-500 uppercase mb-3">
What are you doing?
</p>
<div className="grid md:grid-cols-2 gap-4 mb-6">
<button
type="button"
onClick={() => setMode('create')}
className={`p-6 rounded border-2 transition text-left ${
mode === 'create'
? 'border-[#d4f53c] bg-[#232323]'
: 'border-[#333] bg-[#1a1a1a] hover:border-[#555]'
}`}
>
<div className="mb-3">
<svg className="w-8 h-8 text-[#d4f53c]" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
</svg>
</div>
<p className="font-mono font-bold text-white mb-1">Create a Space</p>
<p className="font-mono text-xs text-zinc-500">set up a new hackerspace</p>
</button>
<button
type="button"
onClick={() => setMode('join')}
className={`p-6 rounded border-2 transition text-left ${
mode === 'join'
? 'border-[#d4f53c] bg-[#232323]'
: 'border-[#333] bg-[#1a1a1a] hover:border-[#555]'
}`}
>
<div className="mb-3">
<svg className="w-8 h-8 text-zinc-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M18 9v3m0 0v3m0-3h3m-3 0h-3m-2-5a4 4 0 11-8 0 4 4 0 018 0zM3 20a6 6 0 0112 0v1H3v-1z" />
</svg>
</div>
<p className="font-mono font-bold text-white mb-1">Join a Space</p>
<p className="font-mono text-xs text-zinc-500">use an invite code</p>
</button>
</div>
{error && <p className="font-mono text-xs text-red-400 mb-4">{error}</p>}
<button
type="submit"
disabled={!mode}
className="w-full bg-[#d4f53c] text-black font-mono text-sm font-bold tracking-widest uppercase py-3 rounded hover:bg-[#c5e635] transition disabled:opacity-40 disabled:cursor-not-allowed"
>
Continue →
</button>
</form>
) : (
<form onSubmit={handleSpaceSubmit}>
{/* Mode picker - show inline for returning users who haven't selected yet */}
{!mode && (
<div className="grid md:grid-cols-2 gap-4 mb-6">
<button
type="button"
onClick={() => setMode('create')}
className="p-6 rounded border-2 border-[#333] bg-[#1a1a1a] hover:border-[#d4f53c] transition text-left"
>
<div className="mb-3">
<svg className="w-8 h-8 text-[#d4f53c]" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
</svg>
</div>
<p className="font-mono font-bold text-white mb-1">Create a Space</p>
<p className="font-mono text-xs text-zinc-500">set up a new hackerspace</p>
</button>
<button
type="button"
onClick={() => setMode('join')}
className="p-6 rounded border-2 border-[#333] bg-[#1a1a1a] hover:border-[#d4f53c] transition text-left"
>
<div className="mb-3">
<svg className="w-8 h-8 text-zinc-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M18 9v3m0 0v3m0-3h3m-3 0h-3m-2-5a4 4 0 11-8 0 4 4 0 018 0zM3 20a6 6 0 0112 0v1H3v-1z" />
</svg>
</div>
<p className="font-mono font-bold text-white mb-1">Join a Space</p>
<p className="font-mono text-xs text-zinc-500">use an invite code</p>
</button>
</div>
)}
{mode === 'create' ? (
<div className="space-y-4 mb-6">
<div className="grid md:grid-cols-2 gap-4">
<div>
<label className="font-mono text-[10px] tracking-widest text-zinc-500 uppercase block mb-1">
Space Name
</label>
<input
type="text"
value={spaceName}
onChange={e => {
setSpaceName(e.target.value)
setSpaceSlug(
e.target.value.toLowerCase().replace(/\s+/g, '').replace(/[^a-z0-9]/g, '')
)
}}
placeholder="HeatSync Labs"
required
className="w-full bg-[#232323] border border-[#333] text-white font-mono text-sm rounded px-3 py-2.5 placeholder:text-zinc-600 focus:outline-none focus:border-[#d4f53c] transition"
/>
</div>
<div>
<label className="font-mono text-[10px] tracking-widest text-zinc-500 uppercase block mb-1">
Slug
</label>
<input
type="text"
value={spaceSlug}
onChange={e => setSpaceSlug(e.target.value.toLowerCase().replace(/[^a-z0-9]/g, ''))}
placeholder="heatsynclabs"
required
pattern="[a-z0-9]+"
title="Lowercase letters and numbers only"
className="w-full bg-[#232323] border border-[#333] text-white font-mono text-sm rounded px-3 py-2.5 placeholder:text-zinc-600 focus:outline-none focus:border-[#d4f53c] transition"
/>
</div>
</div>
<div>
<label className="font-mono text-[10px] tracking-widest text-zinc-500 uppercase block mb-1">
City / Location (Optional)
</label>
<input
type="text"
value={spaceCity}
onChange={e => setSpaceCity(e.target.value)}
placeholder="Mesa, AZ"
className="w-full bg-[#232323] border border-[#333] text-white font-mono text-sm rounded px-3 py-2.5 placeholder:text-zinc-600 focus:outline-none focus:border-[#d4f53c] transition"
/>
</div>
<div className="bg-[#232323] border border-[#333] rounded p-4">
<p className="font-mono text-[10px] text-zinc-500 mb-1">You&apos;ll be the admin of this space.</p>
<p className="font-mono text-[10px] text-zinc-500">Default channels (general, announcements, random) will be created automatically.</p>
</div>
</div>
) : (
<div className="mb-6">
<label className="font-mono text-[10px] tracking-widest text-zinc-500 uppercase block mb-1">
Invite Code
</label>
<input
type="text"
value={inviteCode}
onChange={e => setInviteCode(e.target.value.toUpperCase())}
placeholder="HSL-2025-XXXX"
required
className="w-full bg-[#232323] border border-[#333] text-white font-mono text-sm rounded px-3 py-2.5 placeholder:text-zinc-600 focus:outline-none focus:border-[#d4f53c] transition"
/>
<p className="font-mono text-[10px] text-zinc-600 mt-1.5">Ask your space admin for the invite code from Settings.</p>
</div>
)}
{error && <p className="font-mono text-xs text-red-400 mb-4">{error}</p>}
<div className="flex gap-3">
{!isReturningUser && (
<button
type="button"
onClick={() => { setStep('account'); setError('') }}
className="px-6 bg-[#232323] border border-[#333] text-white font-mono text-sm py-3 rounded hover:border-[#555] transition"
>
Back
</button>
)}
{mode && (
<button
type="submit"
disabled={loading}
className="flex-1 bg-[#d4f53c] text-black font-mono text-sm font-bold tracking-widest uppercase py-3 rounded hover:bg-[#c5e635] transition disabled:opacity-60"
>
{loading ? 'Please wait...' : mode === 'create' ? 'Create Space →' : 'Join Space →'}
</button>
)}
</div>
</form>
)}
{!isReturningUser && (
<p className="text-center font-mono text-xs text-zinc-600 mt-6">
already have an account?{' '}
<Link href="/login" className="text-[#d4f53c] hover:underline">
sign in →
</Link>
</p>
)}
</div>
</div>
</div>
)
}
+21
View File
@@ -0,0 +1,21 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "new-york",
"rsc": true,
"tsx": true,
"tailwind": {
"config": "",
"css": "app/globals.css",
"baseColor": "neutral",
"cssVariables": true,
"prefix": ""
},
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
"ui": "@/components/ui",
"lib": "@/lib",
"hooks": "@/hooks"
},
"iconLibrary": "lucide"
}
+148
View File
@@ -0,0 +1,148 @@
'use client'
import Link from 'next/link'
import { usePathname, useRouter } from 'next/navigation'
import { useState } from 'react'
import { createClient } from '@/lib/supabase/client'
import {
LayoutDashboard, ListChecks, FolderKanban, Settings2, MessageSquare,
Users, CreditCard, BookUser, Download, LogOut, ChevronDown
} from 'lucide-react'
const NAV = [
{
section: 'WORKSPACE',
items: [
{ label: 'Dashboard', href: '/dashboard', icon: LayoutDashboard },
{ label: 'Tasks & Chores', href: '/tasks', icon: ListChecks, badge: 7 },
{ label: 'Projects', href: '/projects', icon: FolderKanban },
{ label: 'Ops & Facilities', href: '/ops', icon: Settings2 },
{ label: 'Comms', href: '/comms', icon: MessageSquare, badge: 3 },
],
},
{
section: 'PEOPLE',
items: [
{ label: 'Members', href: '/members', icon: Users },
{ label: 'Payments', href: '/payments', icon: CreditCard, badge: 12, badgeColor: 'text-orange-500 bg-orange-500/10' },
{ label: 'Contacts', href: '/contacts', icon: BookUser },
],
},
{
section: 'ADMIN',
items: [
{ label: 'Import / Sync', href: '/import', icon: Download },
{ label: 'Settings', href: '/settings', icon: Settings2 },
],
},
]
interface Props {
member: any
space: any
children: React.ReactNode
}
export default function AppShell({ member, space, children }: Props) {
const pathname = usePathname()
const router = useRouter()
const [signingOut, setSigningOut] = useState(false)
async function signOut() {
setSigningOut(true)
const supabase = createClient()
await supabase.auth.signOut()
router.push('/login')
}
const initials = (member.display_name || 'U')
.split(' ')
.map((n: string) => n[0])
.join('')
.toUpperCase()
.slice(0, 2)
return (
<div className="flex h-screen overflow-hidden bg-background">
{/* Sidebar */}
<aside className="w-[230px] flex-shrink-0 bg-sidebar flex flex-col h-full overflow-y-auto">
{/* Logo */}
<div className="px-4 pt-5 pb-4">
<Link href="/dashboard" className="block">
<span className="font-mono text-lg font-bold">
<span className="text-primary">{'{'}</span>
<span className="text-sidebar-foreground">hs</span>
<span className="text-primary">{'}'}</span>
</span>
</Link>
<p className="font-sans text-xs text-sidebar-foreground/50 mt-0.5 truncate">{space.name}</p>
</div>
{/* Nav */}
<nav className="flex-1 px-2 space-y-4">
{NAV.map(({ section, items }) => (
<div key={section}>
<p className="font-mono text-[10px] text-sidebar-foreground/30 tracking-widest px-2 mb-1">
{section}
</p>
{items.map(({ label, href, icon: Icon, badge, badgeColor }) => {
const active = pathname === href || pathname.startsWith(href + '/')
return (
<Link
key={href}
href={href}
className={`flex items-center gap-2.5 px-2 py-1.5 rounded text-sm font-sans transition group ${
active
? 'bg-primary/20 text-primary'
: 'text-sidebar-foreground/70 hover:bg-sidebar-accent hover:text-sidebar-accent-foreground'
}`}
>
<Icon className="w-4 h-4 flex-shrink-0" />
<span className="flex-1 truncate">{label}</span>
{badge ? (
<span className={`text-[10px] font-bold px-1.5 py-0.5 rounded-full ${
badgeColor || 'text-sidebar-foreground/50 bg-sidebar-foreground/10'
}`}>
{badge}
</span>
) : null}
</Link>
)
})}
</div>
))}
</nav>
{/* User footer */}
<div className="p-3 border-t border-sidebar-border">
<div className="flex items-center gap-2.5">
<div className="w-8 h-8 rounded bg-primary flex items-center justify-center text-xs font-mono font-bold text-white flex-shrink-0">
{initials}
</div>
<div className="flex-1 min-w-0">
<p className="text-xs font-sans text-sidebar-foreground truncate">{member.display_name}</p>
<p className="text-[10px] font-mono text-sidebar-foreground/40">
{member.role} · {member.tier}
</p>
</div>
<button
onClick={signOut}
disabled={signingOut}
title="Sign out"
className="text-sidebar-foreground/30 hover:text-sidebar-foreground/70 transition"
>
<LogOut className="w-3.5 h-3.5" />
</button>
</div>
</div>
</aside>
{/* Main content */}
<div className="flex-1 flex flex-col overflow-hidden">
<main className="flex-1 overflow-y-auto">
{children}
</main>
</div>
</div>
)
}
+228
View File
@@ -0,0 +1,228 @@
'use client'
import Link from 'next/link'
import { usePathname, useRouter } from 'next/navigation'
import { useState } from 'react'
import { cn } from '@/lib/utils'
import { signOut } from '@/lib/auth-actions'
import type { SpaceMember, Space } from '@/lib/types'
interface NavItem {
label: string
href: string
icon: React.ReactNode
badge?: number
section: 'workspace' | 'people' | 'admin'
}
function navIcon(path: string) {
const icons: Record<string, React.ReactNode> = {
dashboard: (
<svg xmlns="http://www.w3.org/2000/svg" className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6" />
</svg>
),
tasks: (
<svg xmlns="http://www.w3.org/2000/svg" className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2" />
</svg>
),
projects: (
<svg xmlns="http://www.w3.org/2000/svg" className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M4 5a1 1 0 011-1h4a1 1 0 011 1v4a1 1 0 01-1 1H5a1 1 0 01-1-1V5zM14 5a1 1 0 011-1h4a1 1 0 011 1v4a1 1 0 01-1 1h-4a1 1 0 01-1-1V5zM4 15a1 1 0 011-1h4a1 1 0 011 1v4a1 1 0 01-1 1H5a1 1 0 01-1-1v-4zM14 15a1 1 0 011-1h4a1 1 0 011 1v4a1 1 0 01-1 1h-4a1 1 0 01-1-1v-4z" />
</svg>
),
ops: (
<svg xmlns="http://www.w3.org/2000/svg" className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
),
comms: (
<svg xmlns="http://www.w3.org/2000/svg" className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z" />
</svg>
),
members: (
<svg xmlns="http://www.w3.org/2000/svg" className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
),
payments: (
<svg xmlns="http://www.w3.org/2000/svg" className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M9 17V7m0 10a2 2 0 01-2 2H5a2 2 0 01-2-2V7a2 2 0 012-2h2a2 2 0 012 2m0 10a2 2 0 002 2h2a2 2 0 002-2M9 7a2 2 0 012-2h2a2 2 0 012 2m0 10V7m0 10a2 2 0 002 2h2a2 2 0 002-2V7a2 2 0 00-2-2h-2a2 2 0 00-2 2" />
</svg>
),
contacts: (
<svg xmlns="http://www.w3.org/2000/svg" className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10" />
</svg>
),
import: (
<svg xmlns="http://www.w3.org/2000/svg" className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" />
</svg>
),
settings: (
<svg xmlns="http://www.w3.org/2000/svg" className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
),
}
return icons[path] ?? null
}
interface AppSidebarProps {
member: SpaceMember & { spaces: Space }
taskBadge?: number
commsBadge?: number
paymentBadge?: number
}
export function AppSidebar({ member, taskBadge = 0, commsBadge = 0, paymentBadge = 0 }: AppSidebarProps) {
const pathname = usePathname()
const router = useRouter()
const space = member.spaces
const initials = member.display_name.split(' ').map(n => n[0]).join('').toUpperCase().slice(0, 2)
const isAdmin = member.role === 'admin' || member.role === 'board'
function isActive(href: string) {
return pathname === href || pathname.startsWith(href + '/')
}
async function handleSignOut() {
await signOut()
}
return (
<aside className="w-[230px] shrink-0 bg-[var(--sidebar)] flex flex-col h-screen sticky top-0 border-r border-[var(--sidebar-border)]">
{/* Logo */}
<div className="px-5 py-5 border-b border-[var(--sidebar-border)]">
<Link href="/dashboard" className="flex items-center gap-2.5">
<div className="w-8 h-8 rounded bg-[var(--sidebar-primary)] flex items-center justify-center">
<span className="text-white font-mono text-xs font-bold">{'{'}<span className="text-white">hs</span>{'}'}</span>
</div>
</Link>
<p className="text-[var(--sidebar-foreground)]/60 text-xs mt-1.5 font-mono truncate">{space?.name || 'My Space'}</p>
</div>
{/* Nav */}
<nav className="flex-1 py-4 overflow-y-auto">
{/* WORKSPACE */}
<div className="px-3 mb-1">
<p className="text-[10px] uppercase tracking-widest text-[var(--sidebar-foreground)]/30 px-2 py-1 font-mono">Workspace</p>
</div>
{[
{ label: 'Dashboard', href: '/dashboard', key: 'dashboard', badge: 0 },
{ label: 'Tasks & Chores', href: '/tasks', key: 'tasks', badge: taskBadge },
{ label: 'Projects', href: '/projects', key: 'projects', badge: 0 },
{ label: 'Ops & Facilities', href: '/ops', key: 'ops', badge: 0 },
{ label: 'Comms', href: '/comms', key: 'comms', badge: commsBadge },
].map(item => (
<Link
key={item.href}
href={item.href}
className={cn(
'flex items-center justify-between mx-2 px-3 py-2 rounded-md text-sm transition-colors group',
isActive(item.href)
? 'bg-[var(--sidebar-primary)]/15 text-[var(--sidebar-primary)]'
: 'text-[var(--sidebar-foreground)]/70 hover:bg-[var(--sidebar-accent)] hover:text-[var(--sidebar-foreground)]'
)}
>
<span className="flex items-center gap-2.5">
<span className={cn(isActive(item.href) ? 'text-[var(--sidebar-primary)]' : 'text-[var(--sidebar-foreground)]/50')}>
{navIcon(item.key)}
</span>
{item.label}
</span>
{item.badge > 0 && (
<span className="text-[10px] bg-red-500 text-white rounded-full w-4 h-4 flex items-center justify-center font-medium">
{item.badge}
</span>
)}
</Link>
))}
{/* PEOPLE */}
<div className="px-3 mt-4 mb-1">
<p className="text-[10px] uppercase tracking-widest text-[var(--sidebar-foreground)]/30 px-2 py-1 font-mono">People</p>
</div>
{[
{ label: 'Members', href: '/members', key: 'members', badge: 0 },
{ label: 'Payments', href: '/payments', key: 'payments', badge: paymentBadge },
{ label: 'Contacts', href: '/contacts', key: 'contacts', badge: 0 },
].map(item => (
<Link
key={item.href}
href={item.href}
className={cn(
'flex items-center justify-between mx-2 px-3 py-2 rounded-md text-sm transition-colors',
isActive(item.href)
? 'bg-[var(--sidebar-primary)]/15 text-[var(--sidebar-primary)]'
: 'text-[var(--sidebar-foreground)]/70 hover:bg-[var(--sidebar-accent)] hover:text-[var(--sidebar-foreground)]'
)}
>
<span className="flex items-center gap-2.5">
<span className={cn(isActive(item.href) ? 'text-[var(--sidebar-primary)]' : 'text-[var(--sidebar-foreground)]/50')}>
{navIcon(item.key)}
</span>
{item.label}
</span>
{item.badge > 0 && (
<span className="text-[10px] bg-red-500 text-white rounded-full w-4 h-4 flex items-center justify-center font-medium">
{item.badge}
</span>
)}
</Link>
))}
{/* ADMIN */}
{isAdmin && (
<>
<div className="px-3 mt-4 mb-1">
<p className="text-[10px] uppercase tracking-widest text-[var(--sidebar-foreground)]/30 px-2 py-1 font-mono">Admin</p>
</div>
{[
{ label: 'Import / Sync', href: '/import', key: 'import' },
{ label: 'Settings', href: '/settings', key: 'settings' },
].map(item => (
<Link
key={item.href}
href={item.href}
className={cn(
'flex items-center gap-2.5 mx-2 px-3 py-2 rounded-md text-sm transition-colors',
isActive(item.href)
? 'bg-[var(--sidebar-primary)]/15 text-[var(--sidebar-primary)]'
: 'text-[var(--sidebar-foreground)]/70 hover:bg-[var(--sidebar-accent)] hover:text-[var(--sidebar-foreground)]'
)}
>
<span className={cn(isActive(item.href) ? 'text-[var(--sidebar-primary)]' : 'text-[var(--sidebar-foreground)]/50')}>
{navIcon(item.key)}
</span>
{item.label}
</Link>
))}
</>
)}
</nav>
{/* User */}
<div className="border-t border-[var(--sidebar-border)] p-3">
<button
onClick={handleSignOut}
className="w-full flex items-center gap-3 px-2 py-2 rounded-md hover:bg-[var(--sidebar-accent)] transition-colors group"
>
<div className="w-7 h-7 rounded bg-[var(--sidebar-primary)]/80 flex items-center justify-center text-white text-xs font-bold shrink-0">
{initials}
</div>
<div className="flex-1 text-left min-w-0">
<p className="text-[var(--sidebar-foreground)] text-xs font-medium truncate">{member.display_name}</p>
<p className="text-[var(--sidebar-foreground)]/40 text-[10px] font-mono">{member.role} · {member.tier}</p>
</div>
</button>
</div>
</aside>
)
}
+64
View File
@@ -0,0 +1,64 @@
'use client'
import { useState } from 'react'
import { claimTask, completeTask } from '@/lib/actions'
export function ClaimButton({ taskId }: { taskId: string }) {
const [loading, setLoading] = useState(false)
const [done, setDone] = useState(false)
async function handleClaim() {
setLoading(true)
const result = await claimTask(taskId)
if (!result.error) setDone(true)
setLoading(false)
}
if (done) {
return (
<span className="font-mono text-[10px] text-primary bg-primary/10 px-2 py-0.5 rounded">
CLAIMED
</span>
)
}
return (
<button
onClick={handleClaim}
disabled={loading}
className="font-mono text-[10px] border border-border px-2 py-0.5 rounded hover:border-primary hover:text-primary transition disabled:opacity-50"
>
{loading ? '...' : 'CLAIM'}
</button>
)
}
export function CompleteButton({ taskId }: { taskId: string }) {
const [loading, setLoading] = useState(false)
const [done, setDone] = useState(false)
async function handleComplete() {
setLoading(true)
const result = await completeTask(taskId)
if (!result.error) setDone(true)
setLoading(false)
}
if (done) {
return (
<span className="font-mono text-[10px] text-muted-foreground bg-muted px-2 py-0.5 rounded">
DONE
</span>
)
}
return (
<button
onClick={handleComplete}
disabled={loading}
className="font-mono text-[10px] border border-green-200 text-green-700 px-2 py-0.5 rounded hover:bg-green-50 transition disabled:opacity-50"
>
{loading ? '...' : 'DONE'}
</button>
)
}
+11
View File
@@ -0,0 +1,11 @@
'use client'
import * as React from 'react'
import {
ThemeProvider as NextThemesProvider,
type ThemeProviderProps,
} from 'next-themes'
export function ThemeProvider({ children, ...props }: ThemeProviderProps) {
return <NextThemesProvider {...props}>{children}</NextThemesProvider>
}
+66
View File
@@ -0,0 +1,66 @@
'use client'
import * as React from 'react'
import * as AccordionPrimitive from '@radix-ui/react-accordion'
import { ChevronDownIcon } from 'lucide-react'
import { cn } from '@/lib/utils'
function Accordion({
...props
}: React.ComponentProps<typeof AccordionPrimitive.Root>) {
return <AccordionPrimitive.Root data-slot="accordion" {...props} />
}
function AccordionItem({
className,
...props
}: React.ComponentProps<typeof AccordionPrimitive.Item>) {
return (
<AccordionPrimitive.Item
data-slot="accordion-item"
className={cn('border-b last:border-b-0', className)}
{...props}
/>
)
}
function AccordionTrigger({
className,
children,
...props
}: React.ComponentProps<typeof AccordionPrimitive.Trigger>) {
return (
<AccordionPrimitive.Header className="flex">
<AccordionPrimitive.Trigger
data-slot="accordion-trigger"
className={cn(
'focus-visible:border-ring focus-visible:ring-ring/50 flex flex-1 items-start justify-between gap-4 rounded-md py-4 text-left text-sm font-medium transition-all outline-none hover:underline focus-visible:ring-[3px] disabled:pointer-events-none disabled:opacity-50 [&[data-state=open]>svg]:rotate-180',
className,
)}
{...props}
>
{children}
<ChevronDownIcon className="text-muted-foreground pointer-events-none size-4 shrink-0 translate-y-0.5 transition-transform duration-200" />
</AccordionPrimitive.Trigger>
</AccordionPrimitive.Header>
)
}
function AccordionContent({
className,
children,
...props
}: React.ComponentProps<typeof AccordionPrimitive.Content>) {
return (
<AccordionPrimitive.Content
data-slot="accordion-content"
className="data-[state=closed]:animate-accordion-up data-[state=open]:animate-accordion-down overflow-hidden text-sm"
{...props}
>
<div className={cn('pt-0 pb-4', className)}>{children}</div>
</AccordionPrimitive.Content>
)
}
export { Accordion, AccordionItem, AccordionTrigger, AccordionContent }
+157
View File
@@ -0,0 +1,157 @@
'use client'
import * as React from 'react'
import * as AlertDialogPrimitive from '@radix-ui/react-alert-dialog'
import { cn } from '@/lib/utils'
import { buttonVariants } from '@/components/ui/button'
function AlertDialog({
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Root>) {
return <AlertDialogPrimitive.Root data-slot="alert-dialog" {...props} />
}
function AlertDialogTrigger({
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Trigger>) {
return (
<AlertDialogPrimitive.Trigger data-slot="alert-dialog-trigger" {...props} />
)
}
function AlertDialogPortal({
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Portal>) {
return (
<AlertDialogPrimitive.Portal data-slot="alert-dialog-portal" {...props} />
)
}
function AlertDialogOverlay({
className,
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Overlay>) {
return (
<AlertDialogPrimitive.Overlay
data-slot="alert-dialog-overlay"
className={cn(
'data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50',
className,
)}
{...props}
/>
)
}
function AlertDialogContent({
className,
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Content>) {
return (
<AlertDialogPortal>
<AlertDialogOverlay />
<AlertDialogPrimitive.Content
data-slot="alert-dialog-content"
className={cn(
'bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg',
className,
)}
{...props}
/>
</AlertDialogPortal>
)
}
function AlertDialogHeader({
className,
...props
}: React.ComponentProps<'div'>) {
return (
<div
data-slot="alert-dialog-header"
className={cn('flex flex-col gap-2 text-center sm:text-left', className)}
{...props}
/>
)
}
function AlertDialogFooter({
className,
...props
}: React.ComponentProps<'div'>) {
return (
<div
data-slot="alert-dialog-footer"
className={cn(
'flex flex-col-reverse gap-2 sm:flex-row sm:justify-end',
className,
)}
{...props}
/>
)
}
function AlertDialogTitle({
className,
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Title>) {
return (
<AlertDialogPrimitive.Title
data-slot="alert-dialog-title"
className={cn('text-lg font-semibold', className)}
{...props}
/>
)
}
function AlertDialogDescription({
className,
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Description>) {
return (
<AlertDialogPrimitive.Description
data-slot="alert-dialog-description"
className={cn('text-muted-foreground text-sm', className)}
{...props}
/>
)
}
function AlertDialogAction({
className,
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Action>) {
return (
<AlertDialogPrimitive.Action
className={cn(buttonVariants(), className)}
{...props}
/>
)
}
function AlertDialogCancel({
className,
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Cancel>) {
return (
<AlertDialogPrimitive.Cancel
className={cn(buttonVariants({ variant: 'outline' }), className)}
{...props}
/>
)
}
export {
AlertDialog,
AlertDialogPortal,
AlertDialogOverlay,
AlertDialogTrigger,
AlertDialogContent,
AlertDialogHeader,
AlertDialogFooter,
AlertDialogTitle,
AlertDialogDescription,
AlertDialogAction,
AlertDialogCancel,
}
+66
View File
@@ -0,0 +1,66 @@
import * as React from 'react'
import { cva, type VariantProps } from 'class-variance-authority'
import { cn } from '@/lib/utils'
const alertVariants = cva(
'relative w-full rounded-lg border px-4 py-3 text-sm grid has-[>svg]:grid-cols-[calc(var(--spacing)*4)_1fr] grid-cols-[0_1fr] has-[>svg]:gap-x-3 gap-y-0.5 items-start [&>svg]:size-4 [&>svg]:translate-y-0.5 [&>svg]:text-current',
{
variants: {
variant: {
default: 'bg-card text-card-foreground',
destructive:
'text-destructive bg-card [&>svg]:text-current *:data-[slot=alert-description]:text-destructive/90',
},
},
defaultVariants: {
variant: 'default',
},
},
)
function Alert({
className,
variant,
...props
}: React.ComponentProps<'div'> & VariantProps<typeof alertVariants>) {
return (
<div
data-slot="alert"
role="alert"
className={cn(alertVariants({ variant }), className)}
{...props}
/>
)
}
function AlertTitle({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot="alert-title"
className={cn(
'col-start-2 line-clamp-1 min-h-4 font-medium tracking-tight',
className,
)}
{...props}
/>
)
}
function AlertDescription({
className,
...props
}: React.ComponentProps<'div'>) {
return (
<div
data-slot="alert-description"
className={cn(
'text-muted-foreground col-start-2 grid justify-items-start gap-1 text-sm [&_p]:leading-relaxed',
className,
)}
{...props}
/>
)
}
export { Alert, AlertTitle, AlertDescription }
+11
View File
@@ -0,0 +1,11 @@
'use client'
import * as AspectRatioPrimitive from '@radix-ui/react-aspect-ratio'
function AspectRatio({
...props
}: React.ComponentProps<typeof AspectRatioPrimitive.Root>) {
return <AspectRatioPrimitive.Root data-slot="aspect-ratio" {...props} />
}
export { AspectRatio }
+53
View File
@@ -0,0 +1,53 @@
'use client'
import * as React from 'react'
import * as AvatarPrimitive from '@radix-ui/react-avatar'
import { cn } from '@/lib/utils'
function Avatar({
className,
...props
}: React.ComponentProps<typeof AvatarPrimitive.Root>) {
return (
<AvatarPrimitive.Root
data-slot="avatar"
className={cn(
'relative flex size-8 shrink-0 overflow-hidden rounded-full',
className,
)}
{...props}
/>
)
}
function AvatarImage({
className,
...props
}: React.ComponentProps<typeof AvatarPrimitive.Image>) {
return (
<AvatarPrimitive.Image
data-slot="avatar-image"
className={cn('aspect-square size-full', className)}
{...props}
/>
)
}
function AvatarFallback({
className,
...props
}: React.ComponentProps<typeof AvatarPrimitive.Fallback>) {
return (
<AvatarPrimitive.Fallback
data-slot="avatar-fallback"
className={cn(
'bg-muted flex size-full items-center justify-center rounded-full',
className,
)}
{...props}
/>
)
}
export { Avatar, AvatarImage, AvatarFallback }
+46
View File
@@ -0,0 +1,46 @@
import * as React from 'react'
import { Slot } from '@radix-ui/react-slot'
import { cva, type VariantProps } from 'class-variance-authority'
import { cn } from '@/lib/utils'
const badgeVariants = cva(
'inline-flex items-center justify-center rounded-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: {
variant: {
default:
'border-transparent bg-primary text-primary-foreground [a&]:hover:bg-primary/90',
secondary:
'border-transparent bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90',
destructive:
'border-transparent bg-destructive text-white [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60',
outline:
'text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground',
},
},
defaultVariants: {
variant: 'default',
},
},
)
function Badge({
className,
variant,
asChild = false,
...props
}: React.ComponentProps<'span'> &
VariantProps<typeof badgeVariants> & { asChild?: boolean }) {
const Comp = asChild ? Slot : 'span'
return (
<Comp
data-slot="badge"
className={cn(badgeVariants({ variant }), className)}
{...props}
/>
)
}
export { Badge, badgeVariants }
+109
View File
@@ -0,0 +1,109 @@
import * as React from 'react'
import { Slot } from '@radix-ui/react-slot'
import { ChevronRight, MoreHorizontal } from 'lucide-react'
import { cn } from '@/lib/utils'
function Breadcrumb({ ...props }: React.ComponentProps<'nav'>) {
return <nav aria-label="breadcrumb" data-slot="breadcrumb" {...props} />
}
function BreadcrumbList({ className, ...props }: React.ComponentProps<'ol'>) {
return (
<ol
data-slot="breadcrumb-list"
className={cn(
'text-muted-foreground flex flex-wrap items-center gap-1.5 text-sm break-words sm:gap-2.5',
className,
)}
{...props}
/>
)
}
function BreadcrumbItem({ className, ...props }: React.ComponentProps<'li'>) {
return (
<li
data-slot="breadcrumb-item"
className={cn('inline-flex items-center gap-1.5', className)}
{...props}
/>
)
}
function BreadcrumbLink({
asChild,
className,
...props
}: React.ComponentProps<'a'> & {
asChild?: boolean
}) {
const Comp = asChild ? Slot : 'a'
return (
<Comp
data-slot="breadcrumb-link"
className={cn('hover:text-foreground transition-colors', className)}
{...props}
/>
)
}
function BreadcrumbPage({ className, ...props }: React.ComponentProps<'span'>) {
return (
<span
data-slot="breadcrumb-page"
role="link"
aria-disabled="true"
aria-current="page"
className={cn('text-foreground font-normal', className)}
{...props}
/>
)
}
function BreadcrumbSeparator({
children,
className,
...props
}: React.ComponentProps<'li'>) {
return (
<li
data-slot="breadcrumb-separator"
role="presentation"
aria-hidden="true"
className={cn('[&>svg]:size-3.5', className)}
{...props}
>
{children ?? <ChevronRight />}
</li>
)
}
function BreadcrumbEllipsis({
className,
...props
}: React.ComponentProps<'span'>) {
return (
<span
data-slot="breadcrumb-ellipsis"
role="presentation"
aria-hidden="true"
className={cn('flex size-9 items-center justify-center', className)}
{...props}
>
<MoreHorizontal className="size-4" />
<span className="sr-only">More</span>
</span>
)
}
export {
Breadcrumb,
BreadcrumbList,
BreadcrumbItem,
BreadcrumbLink,
BreadcrumbPage,
BreadcrumbSeparator,
BreadcrumbEllipsis,
}
+83
View File
@@ -0,0 +1,83 @@
import { Slot } from '@radix-ui/react-slot'
import { cva, type VariantProps } from 'class-variance-authority'
import { cn } from '@/lib/utils'
import { Separator } from '@/components/ui/separator'
const buttonGroupVariants = cva(
"flex w-fit items-stretch [&>*]:focus-visible:z-10 [&>*]:focus-visible:relative [&>[data-slot=select-trigger]:not([class*='w-'])]:w-fit [&>input]:flex-1 has-[select[aria-hidden=true]:last-child]:[&>[data-slot=select-trigger]:last-of-type]:rounded-r-md has-[>[data-slot=button-group]]:gap-2",
{
variants: {
orientation: {
horizontal:
'[&>*:not(:first-child)]:rounded-l-none [&>*:not(:first-child)]:border-l-0 [&>*:not(:last-child)]:rounded-r-none',
vertical:
'flex-col [&>*:not(:first-child)]:rounded-t-none [&>*:not(:first-child)]:border-t-0 [&>*:not(:last-child)]:rounded-b-none',
},
},
defaultVariants: {
orientation: 'horizontal',
},
},
)
function ButtonGroup({
className,
orientation,
...props
}: React.ComponentProps<'div'> & VariantProps<typeof buttonGroupVariants>) {
return (
<div
role="group"
data-slot="button-group"
data-orientation={orientation}
className={cn(buttonGroupVariants({ orientation }), className)}
{...props}
/>
)
}
function ButtonGroupText({
className,
asChild = false,
...props
}: React.ComponentProps<'div'> & {
asChild?: boolean
}) {
const Comp = asChild ? Slot : 'div'
return (
<Comp
className={cn(
"bg-muted flex items-center gap-2 rounded-md border px-4 text-sm font-medium shadow-xs [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4",
className,
)}
{...props}
/>
)
}
function ButtonGroupSeparator({
className,
orientation = 'vertical',
...props
}: React.ComponentProps<typeof Separator>) {
return (
<Separator
data-slot="button-group-separator"
orientation={orientation}
className={cn(
'bg-input relative !m-0 self-stretch data-[orientation=vertical]:h-auto',
className,
)}
{...props}
/>
)
}
export {
ButtonGroup,
ButtonGroupSeparator,
ButtonGroupText,
buttonGroupVariants,
}
+60
View File
@@ -0,0 +1,60 @@
import * as React from 'react'
import { Slot } from '@radix-ui/react-slot'
import { cva, type VariantProps } from 'class-variance-authority'
import { cn } from '@/lib/utils'
const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium 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: {
variant: {
default: 'bg-primary text-primary-foreground hover:bg-primary/90',
destructive:
'bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60',
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',
secondary:
'bg-secondary text-secondary-foreground hover:bg-secondary/80',
ghost:
'hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50',
link: 'text-primary underline-offset-4 hover:underline',
},
size: {
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',
lg: 'h-10 rounded-md px-6 has-[>svg]:px-4',
icon: 'size-9',
'icon-sm': 'size-8',
'icon-lg': 'size-10',
},
},
defaultVariants: {
variant: 'default',
size: 'default',
},
},
)
function Button({
className,
variant,
size,
asChild = false,
...props
}: React.ComponentProps<'button'> &
VariantProps<typeof buttonVariants> & {
asChild?: boolean
}) {
const Comp = asChild ? Slot : 'button'
return (
<Comp
data-slot="button"
className={cn(buttonVariants({ variant, size, className }))}
{...props}
/>
)
}
export { Button, buttonVariants }
+213
View File
@@ -0,0 +1,213 @@
'use client'
import * as React from 'react'
import {
ChevronDownIcon,
ChevronLeftIcon,
ChevronRightIcon,
} from 'lucide-react'
import { DayButton, DayPicker, getDefaultClassNames } from 'react-day-picker'
import { cn } from '@/lib/utils'
import { Button, buttonVariants } from '@/components/ui/button'
function Calendar({
className,
classNames,
showOutsideDays = true,
captionLayout = 'label',
buttonVariant = 'ghost',
formatters,
components,
...props
}: React.ComponentProps<typeof DayPicker> & {
buttonVariant?: React.ComponentProps<typeof Button>['variant']
}) {
const defaultClassNames = getDefaultClassNames()
return (
<DayPicker
showOutsideDays={showOutsideDays}
className={cn(
'bg-background group/calendar p-3 [--cell-size:--spacing(8)] [[data-slot=card-content]_&]:bg-transparent [[data-slot=popover-content]_&]:bg-transparent',
String.raw`rtl:**:[.rdp-button\_next>svg]:rotate-180`,
String.raw`rtl:**:[.rdp-button\_previous>svg]:rotate-180`,
className,
)}
captionLayout={captionLayout}
formatters={{
formatMonthDropdown: (date) =>
date.toLocaleString('default', { month: 'short' }),
...formatters,
}}
classNames={{
root: cn('w-fit', defaultClassNames.root),
months: cn(
'flex gap-4 flex-col md:flex-row relative',
defaultClassNames.months,
),
month: cn('flex flex-col w-full gap-4', defaultClassNames.month),
nav: cn(
'flex items-center gap-1 w-full absolute top-0 inset-x-0 justify-between',
defaultClassNames.nav,
),
button_previous: cn(
buttonVariants({ variant: buttonVariant }),
'size-(--cell-size) aria-disabled:opacity-50 p-0 select-none',
defaultClassNames.button_previous,
),
button_next: cn(
buttonVariants({ variant: buttonVariant }),
'size-(--cell-size) aria-disabled:opacity-50 p-0 select-none',
defaultClassNames.button_next,
),
month_caption: cn(
'flex items-center justify-center h-(--cell-size) w-full px-(--cell-size)',
defaultClassNames.month_caption,
),
dropdowns: cn(
'w-full flex items-center text-sm font-medium justify-center h-(--cell-size) gap-1.5',
defaultClassNames.dropdowns,
),
dropdown_root: cn(
'relative has-focus:border-ring border border-input shadow-xs has-focus:ring-ring/50 has-focus:ring-[3px] rounded-md',
defaultClassNames.dropdown_root,
),
dropdown: cn(
'absolute bg-popover inset-0 opacity-0',
defaultClassNames.dropdown,
),
caption_label: cn(
'select-none font-medium',
captionLayout === 'label'
? 'text-sm'
: 'rounded-md pl-2 pr-1 flex items-center gap-1 text-sm h-8 [&>svg]:text-muted-foreground [&>svg]:size-3.5',
defaultClassNames.caption_label,
),
table: 'w-full border-collapse',
weekdays: cn('flex', defaultClassNames.weekdays),
weekday: cn(
'text-muted-foreground rounded-md flex-1 font-normal text-[0.8rem] select-none',
defaultClassNames.weekday,
),
week: cn('flex w-full mt-2', defaultClassNames.week),
week_number_header: cn(
'select-none w-(--cell-size)',
defaultClassNames.week_number_header,
),
week_number: cn(
'text-[0.8rem] select-none text-muted-foreground',
defaultClassNames.week_number,
),
day: cn(
'relative w-full h-full p-0 text-center [&: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,
),
range_start: cn(
'rounded-l-md bg-accent',
defaultClassNames.range_start,
),
range_middle: cn('rounded-none', defaultClassNames.range_middle),
range_end: cn('rounded-r-md bg-accent', defaultClassNames.range_end),
today: cn(
'bg-accent text-accent-foreground rounded-md data-[selected=true]:rounded-none',
defaultClassNames.today,
),
outside: cn(
'text-muted-foreground aria-selected:text-muted-foreground',
defaultClassNames.outside,
),
disabled: cn(
'text-muted-foreground opacity-50',
defaultClassNames.disabled,
),
hidden: cn('invisible', defaultClassNames.hidden),
...classNames,
}}
components={{
Root: ({ className, rootRef, ...props }) => {
return (
<div
data-slot="calendar"
ref={rootRef}
className={cn(className)}
{...props}
/>
)
},
Chevron: ({ className, orientation, ...props }) => {
if (orientation === 'left') {
return (
<ChevronLeftIcon className={cn('size-4', className)} {...props} />
)
}
if (orientation === 'right') {
return (
<ChevronRightIcon
className={cn('size-4', className)}
{...props}
/>
)
}
return (
<ChevronDownIcon className={cn('size-4', className)} {...props} />
)
},
DayButton: CalendarDayButton,
WeekNumber: ({ children, ...props }) => {
return (
<td {...props}>
<div className="flex size-(--cell-size) items-center justify-center text-center">
{children}
</div>
</td>
)
},
...components,
}}
{...props}
/>
)
}
function CalendarDayButton({
className,
day,
modifiers,
...props
}: React.ComponentProps<typeof DayButton>) {
const defaultClassNames = getDefaultClassNames()
const ref = React.useRef<HTMLButtonElement>(null)
React.useEffect(() => {
if (modifiers.focused) ref.current?.focus()
}, [modifiers.focused])
return (
<Button
ref={ref}
variant="ghost"
size="icon"
data-day={day.date.toLocaleDateString()}
data-selected-single={
modifiers.selected &&
!modifiers.range_start &&
!modifiers.range_end &&
!modifiers.range_middle
}
data-range-start={modifiers.range_start}
data-range-end={modifiers.range_end}
data-range-middle={modifiers.range_middle}
className={cn(
'data-[selected-single=true]:bg-primary data-[selected-single=true]:text-primary-foreground data-[range-middle=true]:bg-accent data-[range-middle=true]:text-accent-foreground data-[range-start=true]:bg-primary data-[range-start=true]:text-primary-foreground data-[range-end=true]:bg-primary data-[range-end=true]:text-primary-foreground group-data-[focused=true]/day:border-ring group-data-[focused=true]/day:ring-ring/50 dark:hover:text-accent-foreground flex aspect-square size-auto w-full min-w-(--cell-size) flex-col gap-1 leading-none font-normal group-data-[focused=true]/day:relative group-data-[focused=true]/day:z-10 group-data-[focused=true]/day:ring-[3px] data-[range-end=true]:rounded-md data-[range-end=true]:rounded-r-md data-[range-middle=true]:rounded-none data-[range-start=true]:rounded-md data-[range-start=true]:rounded-l-md [&>span]:text-xs [&>span]:opacity-70',
defaultClassNames.day,
className,
)}
{...props}
/>
)
}
export { Calendar, CalendarDayButton }
+92
View File
@@ -0,0 +1,92 @@
import * as React from 'react'
import { cn } from '@/lib/utils'
function Card({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot="card"
className={cn(
'bg-card text-card-foreground flex flex-col gap-6 rounded-xl border py-6 shadow-sm',
className,
)}
{...props}
/>
)
}
function CardHeader({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot="card-header"
className={cn(
'@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-2 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6',
className,
)}
{...props}
/>
)
}
function CardTitle({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot="card-title"
className={cn('leading-none font-semibold', className)}
{...props}
/>
)
}
function CardDescription({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot="card-description"
className={cn('text-muted-foreground text-sm', className)}
{...props}
/>
)
}
function CardAction({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot="card-action"
className={cn(
'col-start-2 row-span-2 row-start-1 self-start justify-self-end',
className,
)}
{...props}
/>
)
}
function CardContent({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot="card-content"
className={cn('px-6', className)}
{...props}
/>
)
}
function CardFooter({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot="card-footer"
className={cn('flex items-center px-6 [.border-t]:pt-6', className)}
{...props}
/>
)
}
export {
Card,
CardHeader,
CardFooter,
CardTitle,
CardAction,
CardDescription,
CardContent,
}
+241
View File
@@ -0,0 +1,241 @@
'use client'
import * as React from 'react'
import useEmblaCarousel, {
type UseEmblaCarouselType,
} from 'embla-carousel-react'
import { ArrowLeft, ArrowRight } from 'lucide-react'
import { cn } from '@/lib/utils'
import { Button } from '@/components/ui/button'
type CarouselApi = UseEmblaCarouselType[1]
type UseCarouselParameters = Parameters<typeof useEmblaCarousel>
type CarouselOptions = UseCarouselParameters[0]
type CarouselPlugin = UseCarouselParameters[1]
type CarouselProps = {
opts?: CarouselOptions
plugins?: CarouselPlugin
orientation?: 'horizontal' | 'vertical'
setApi?: (api: CarouselApi) => void
}
type CarouselContextProps = {
carouselRef: ReturnType<typeof useEmblaCarousel>[0]
api: ReturnType<typeof useEmblaCarousel>[1]
scrollPrev: () => void
scrollNext: () => void
canScrollPrev: boolean
canScrollNext: boolean
} & CarouselProps
const CarouselContext = React.createContext<CarouselContextProps | null>(null)
function useCarousel() {
const context = React.useContext(CarouselContext)
if (!context) {
throw new Error('useCarousel must be used within a <Carousel />')
}
return context
}
function Carousel({
orientation = 'horizontal',
opts,
setApi,
plugins,
className,
children,
...props
}: React.ComponentProps<'div'> & CarouselProps) {
const [carouselRef, api] = useEmblaCarousel(
{
...opts,
axis: orientation === 'horizontal' ? 'x' : 'y',
},
plugins,
)
const [canScrollPrev, setCanScrollPrev] = React.useState(false)
const [canScrollNext, setCanScrollNext] = React.useState(false)
const onSelect = React.useCallback((api: CarouselApi) => {
if (!api) return
setCanScrollPrev(api.canScrollPrev())
setCanScrollNext(api.canScrollNext())
}, [])
const scrollPrev = React.useCallback(() => {
api?.scrollPrev()
}, [api])
const scrollNext = React.useCallback(() => {
api?.scrollNext()
}, [api])
const handleKeyDown = React.useCallback(
(event: React.KeyboardEvent<HTMLDivElement>) => {
if (event.key === 'ArrowLeft') {
event.preventDefault()
scrollPrev()
} else if (event.key === 'ArrowRight') {
event.preventDefault()
scrollNext()
}
},
[scrollPrev, scrollNext],
)
React.useEffect(() => {
if (!api || !setApi) return
setApi(api)
}, [api, setApi])
React.useEffect(() => {
if (!api) return
onSelect(api)
api.on('reInit', onSelect)
api.on('select', onSelect)
return () => {
api?.off('select', onSelect)
}
}, [api, onSelect])
return (
<CarouselContext.Provider
value={{
carouselRef,
api: api,
opts,
orientation:
orientation || (opts?.axis === 'y' ? 'vertical' : 'horizontal'),
scrollPrev,
scrollNext,
canScrollPrev,
canScrollNext,
}}
>
<div
onKeyDownCapture={handleKeyDown}
className={cn('relative', className)}
role="region"
aria-roledescription="carousel"
data-slot="carousel"
{...props}
>
{children}
</div>
</CarouselContext.Provider>
)
}
function CarouselContent({ className, ...props }: React.ComponentProps<'div'>) {
const { carouselRef, orientation } = useCarousel()
return (
<div
ref={carouselRef}
className="overflow-hidden"
data-slot="carousel-content"
>
<div
className={cn(
'flex',
orientation === 'horizontal' ? '-ml-4' : '-mt-4 flex-col',
className,
)}
{...props}
/>
</div>
)
}
function CarouselItem({ className, ...props }: React.ComponentProps<'div'>) {
const { orientation } = useCarousel()
return (
<div
role="group"
aria-roledescription="slide"
data-slot="carousel-item"
className={cn(
'min-w-0 shrink-0 grow-0 basis-full',
orientation === 'horizontal' ? 'pl-4' : 'pt-4',
className,
)}
{...props}
/>
)
}
function CarouselPrevious({
className,
variant = 'outline',
size = 'icon',
...props
}: React.ComponentProps<typeof Button>) {
const { orientation, scrollPrev, canScrollPrev } = useCarousel()
return (
<Button
data-slot="carousel-previous"
variant={variant}
size={size}
className={cn(
'absolute size-8 rounded-full',
orientation === 'horizontal'
? 'top-1/2 -left-12 -translate-y-1/2'
: '-top-12 left-1/2 -translate-x-1/2 rotate-90',
className,
)}
disabled={!canScrollPrev}
onClick={scrollPrev}
{...props}
>
<ArrowLeft />
<span className="sr-only">Previous slide</span>
</Button>
)
}
function CarouselNext({
className,
variant = 'outline',
size = 'icon',
...props
}: React.ComponentProps<typeof Button>) {
const { orientation, scrollNext, canScrollNext } = useCarousel()
return (
<Button
data-slot="carousel-next"
variant={variant}
size={size}
className={cn(
'absolute size-8 rounded-full',
orientation === 'horizontal'
? 'top-1/2 -right-12 -translate-y-1/2'
: '-bottom-12 left-1/2 -translate-x-1/2 rotate-90',
className,
)}
disabled={!canScrollNext}
onClick={scrollNext}
{...props}
>
<ArrowRight />
<span className="sr-only">Next slide</span>
</Button>
)
}
export {
type CarouselApi,
Carousel,
CarouselContent,
CarouselItem,
CarouselPrevious,
CarouselNext,
}
+353
View File
@@ -0,0 +1,353 @@
'use client'
import * as React from 'react'
import * as RechartsPrimitive from 'recharts'
import { cn } from '@/lib/utils'
// Format: { THEME_NAME: CSS_SELECTOR }
const THEMES = { light: '', dark: '.dark' } as const
export type ChartConfig = {
[k in string]: {
label?: React.ReactNode
icon?: React.ComponentType
} & (
| { color?: string; theme?: never }
| { color?: never; theme: Record<keyof typeof THEMES, string> }
)
}
type ChartContextProps = {
config: ChartConfig
}
const ChartContext = React.createContext<ChartContextProps | null>(null)
function useChart() {
const context = React.useContext(ChartContext)
if (!context) {
throw new Error('useChart must be used within a <ChartContainer />')
}
return context
}
function ChartContainer({
id,
className,
children,
config,
...props
}: React.ComponentProps<'div'> & {
config: ChartConfig
children: React.ComponentProps<
typeof RechartsPrimitive.ResponsiveContainer
>['children']
}) {
const uniqueId = React.useId()
const chartId = `chart-${id || uniqueId.replace(/:/g, '')}`
return (
<ChartContext.Provider value={{ config }}>
<div
data-slot="chart"
data-chart={chartId}
className={cn(
"[&_.recharts-cartesian-axis-tick_text]:fill-muted-foreground [&_.recharts-cartesian-grid_line[stroke='#ccc']]:stroke-border/50 [&_.recharts-curve.recharts-tooltip-cursor]:stroke-border [&_.recharts-polar-grid_[stroke='#ccc']]:stroke-border [&_.recharts-radial-bar-background-sector]:fill-muted [&_.recharts-rectangle.recharts-tooltip-cursor]:fill-muted [&_.recharts-reference-line_[stroke='#ccc']]:stroke-border flex aspect-video justify-center text-xs [&_.recharts-dot[stroke='#fff']]:stroke-transparent [&_.recharts-layer]:outline-hidden [&_.recharts-sector]:outline-hidden [&_.recharts-sector[stroke='#fff']]:stroke-transparent [&_.recharts-surface]:outline-hidden",
className,
)}
{...props}
>
<ChartStyle id={chartId} config={config} />
<RechartsPrimitive.ResponsiveContainer>
{children}
</RechartsPrimitive.ResponsiveContainer>
</div>
</ChartContext.Provider>
)
}
const ChartStyle = ({ id, config }: { id: string; config: ChartConfig }) => {
const colorConfig = Object.entries(config).filter(
([, config]) => config.theme || config.color,
)
if (!colorConfig.length) {
return null
}
return (
<style
dangerouslySetInnerHTML={{
__html: Object.entries(THEMES)
.map(
([theme, prefix]) => `
${prefix} [data-chart=${id}] {
${colorConfig
.map(([key, itemConfig]) => {
const color =
itemConfig.theme?.[theme as keyof typeof itemConfig.theme] ||
itemConfig.color
return color ? ` --color-${key}: ${color};` : null
})
.join('\n')}
}
`,
)
.join('\n'),
}}
/>
)
}
const ChartTooltip = RechartsPrimitive.Tooltip
function ChartTooltipContent({
active,
payload,
className,
indicator = 'dot',
hideLabel = false,
hideIndicator = false,
label,
labelFormatter,
labelClassName,
formatter,
color,
nameKey,
labelKey,
}: React.ComponentProps<typeof RechartsPrimitive.Tooltip> &
React.ComponentProps<'div'> & {
hideLabel?: boolean
hideIndicator?: boolean
indicator?: 'line' | 'dot' | 'dashed'
nameKey?: string
labelKey?: string
}) {
const { config } = useChart()
const tooltipLabel = React.useMemo(() => {
if (hideLabel || !payload?.length) {
return null
}
const [item] = payload
const key = `${labelKey || item?.dataKey || item?.name || 'value'}`
const itemConfig = getPayloadConfigFromPayload(config, item, key)
const value =
!labelKey && typeof label === 'string'
? config[label as keyof typeof config]?.label || label
: itemConfig?.label
if (labelFormatter) {
return (
<div className={cn('font-medium', labelClassName)}>
{labelFormatter(value, payload)}
</div>
)
}
if (!value) {
return null
}
return <div className={cn('font-medium', labelClassName)}>{value}</div>
}, [
label,
labelFormatter,
payload,
hideLabel,
labelClassName,
config,
labelKey,
])
if (!active || !payload?.length) {
return null
}
const nestLabel = payload.length === 1 && indicator !== 'dot'
return (
<div
className={cn(
'border-border/50 bg-background grid min-w-[8rem] items-start gap-1.5 rounded-lg border px-2.5 py-1.5 text-xs shadow-xl',
className,
)}
>
{!nestLabel ? tooltipLabel : null}
<div className="grid gap-1.5">
{payload.map((item, index) => {
const key = `${nameKey || item.name || item.dataKey || 'value'}`
const itemConfig = getPayloadConfigFromPayload(config, item, key)
const indicatorColor = color || item.payload.fill || item.color
return (
<div
key={item.dataKey}
className={cn(
'[&>svg]:text-muted-foreground flex w-full flex-wrap items-stretch gap-2 [&>svg]:h-2.5 [&>svg]:w-2.5',
indicator === 'dot' && 'items-center',
)}
>
{formatter && item?.value !== undefined && item.name ? (
formatter(item.value, item.name, item, index, item.payload)
) : (
<>
{itemConfig?.icon ? (
<itemConfig.icon />
) : (
!hideIndicator && (
<div
className={cn(
'shrink-0 rounded-[2px] border-(--color-border) bg-(--color-bg)',
{
'h-2.5 w-2.5': indicator === 'dot',
'w-1': indicator === 'line',
'w-0 border-[1.5px] border-dashed bg-transparent':
indicator === 'dashed',
'my-0.5': nestLabel && indicator === 'dashed',
},
)}
style={
{
'--color-bg': indicatorColor,
'--color-border': indicatorColor,
} as React.CSSProperties
}
/>
)
)}
<div
className={cn(
'flex flex-1 justify-between leading-none',
nestLabel ? 'items-end' : 'items-center',
)}
>
<div className="grid gap-1.5">
{nestLabel ? tooltipLabel : null}
<span className="text-muted-foreground">
{itemConfig?.label || item.name}
</span>
</div>
{item.value && (
<span className="text-foreground font-mono font-medium tabular-nums">
{item.value.toLocaleString()}
</span>
)}
</div>
</>
)}
</div>
)
})}
</div>
</div>
)
}
const ChartLegend = RechartsPrimitive.Legend
function ChartLegendContent({
className,
hideIcon = false,
payload,
verticalAlign = 'bottom',
nameKey,
}: React.ComponentProps<'div'> &
Pick<RechartsPrimitive.LegendProps, 'payload' | 'verticalAlign'> & {
hideIcon?: boolean
nameKey?: string
}) {
const { config } = useChart()
if (!payload?.length) {
return null
}
return (
<div
className={cn(
'flex items-center justify-center gap-4',
verticalAlign === 'top' ? 'pb-3' : 'pt-3',
className,
)}
>
{payload.map((item) => {
const key = `${nameKey || item.dataKey || 'value'}`
const itemConfig = getPayloadConfigFromPayload(config, item, key)
return (
<div
key={item.value}
className={
'[&>svg]:text-muted-foreground flex items-center gap-1.5 [&>svg]:h-3 [&>svg]:w-3'
}
>
{itemConfig?.icon && !hideIcon ? (
<itemConfig.icon />
) : (
<div
className="h-2 w-2 shrink-0 rounded-[2px]"
style={{
backgroundColor: item.color,
}}
/>
)}
{itemConfig?.label}
</div>
)
})}
</div>
)
}
// Helper to extract item config from a payload.
function getPayloadConfigFromPayload(
config: ChartConfig,
payload: unknown,
key: string,
) {
if (typeof payload !== 'object' || payload === null) {
return undefined
}
const payloadPayload =
'payload' in payload &&
typeof payload.payload === 'object' &&
payload.payload !== null
? payload.payload
: undefined
let configLabelKey: string = key
if (
key in payload &&
typeof payload[key as keyof typeof payload] === 'string'
) {
configLabelKey = payload[key as keyof typeof payload] as string
} else if (
payloadPayload &&
key in payloadPayload &&
typeof payloadPayload[key as keyof typeof payloadPayload] === 'string'
) {
configLabelKey = payloadPayload[
key as keyof typeof payloadPayload
] as string
}
return configLabelKey in config
? config[configLabelKey]
: config[key as keyof typeof config]
}
export {
ChartContainer,
ChartTooltip,
ChartTooltipContent,
ChartLegend,
ChartLegendContent,
ChartStyle,
}
+32
View File
@@ -0,0 +1,32 @@
'use client'
import * as React from 'react'
import * as CheckboxPrimitive from '@radix-ui/react-checkbox'
import { CheckIcon } from 'lucide-react'
import { cn } from '@/lib/utils'
function Checkbox({
className,
...props
}: React.ComponentProps<typeof CheckboxPrimitive.Root>) {
return (
<CheckboxPrimitive.Root
data-slot="checkbox"
className={cn(
'peer border-input dark:bg-input/30 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground dark:data-[state=checked]:bg-primary data-[state=checked]:border-primary focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive size-4 shrink-0 rounded-[4px] border shadow-xs transition-shadow outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50',
className,
)}
{...props}
>
<CheckboxPrimitive.Indicator
data-slot="checkbox-indicator"
className="flex items-center justify-center text-current transition-none"
>
<CheckIcon className="size-3.5" />
</CheckboxPrimitive.Indicator>
</CheckboxPrimitive.Root>
)
}
export { Checkbox }
+33
View File
@@ -0,0 +1,33 @@
'use client'
import * as CollapsiblePrimitive from '@radix-ui/react-collapsible'
function Collapsible({
...props
}: React.ComponentProps<typeof CollapsiblePrimitive.Root>) {
return <CollapsiblePrimitive.Root data-slot="collapsible" {...props} />
}
function CollapsibleTrigger({
...props
}: React.ComponentProps<typeof CollapsiblePrimitive.CollapsibleTrigger>) {
return (
<CollapsiblePrimitive.CollapsibleTrigger
data-slot="collapsible-trigger"
{...props}
/>
)
}
function CollapsibleContent({
...props
}: React.ComponentProps<typeof CollapsiblePrimitive.CollapsibleContent>) {
return (
<CollapsiblePrimitive.CollapsibleContent
data-slot="collapsible-content"
{...props}
/>
)
}
export { Collapsible, CollapsibleTrigger, CollapsibleContent }
+184
View File
@@ -0,0 +1,184 @@
'use client'
import * as React from 'react'
import { Command as CommandPrimitive } from 'cmdk'
import { SearchIcon } from 'lucide-react'
import { cn } from '@/lib/utils'
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
function Command({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive>) {
return (
<CommandPrimitive
data-slot="command"
className={cn(
'bg-popover text-popover-foreground flex h-full w-full flex-col overflow-hidden rounded-md',
className,
)}
{...props}
/>
)
}
function CommandDialog({
title = 'Command Palette',
description = 'Search for a command to run...',
children,
className,
showCloseButton = true,
...props
}: React.ComponentProps<typeof Dialog> & {
title?: string
description?: string
className?: string
showCloseButton?: boolean
}) {
return (
<Dialog {...props}>
<DialogHeader className="sr-only">
<DialogTitle>{title}</DialogTitle>
<DialogDescription>{description}</DialogDescription>
</DialogHeader>
<DialogContent
className={cn('overflow-hidden p-0', className)}
showCloseButton={showCloseButton}
>
<Command className="[&_[cmdk-group-heading]]:text-muted-foreground **:data-[slot=command-input-wrapper]:h-12 [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group]]:px-2 [&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0 [&_[cmdk-input-wrapper]_svg]:h-5 [&_[cmdk-input-wrapper]_svg]:w-5 [&_[cmdk-input]]:h-12 [&_[cmdk-item]]:px-2 [&_[cmdk-item]]:py-3 [&_[cmdk-item]_svg]:h-5 [&_[cmdk-item]_svg]:w-5">
{children}
</Command>
</DialogContent>
</Dialog>
)
}
function CommandInput({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.Input>) {
return (
<div
data-slot="command-input-wrapper"
className="flex h-9 items-center gap-2 border-b px-3"
>
<SearchIcon className="size-4 shrink-0 opacity-50" />
<CommandPrimitive.Input
data-slot="command-input"
className={cn(
'placeholder:text-muted-foreground flex h-10 w-full rounded-md bg-transparent py-3 text-sm outline-hidden disabled:cursor-not-allowed disabled:opacity-50',
className,
)}
{...props}
/>
</div>
)
}
function CommandList({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.List>) {
return (
<CommandPrimitive.List
data-slot="command-list"
className={cn(
'max-h-[300px] scroll-py-1 overflow-x-hidden overflow-y-auto',
className,
)}
{...props}
/>
)
}
function CommandEmpty({
...props
}: React.ComponentProps<typeof CommandPrimitive.Empty>) {
return (
<CommandPrimitive.Empty
data-slot="command-empty"
className="py-6 text-center text-sm"
{...props}
/>
)
}
function CommandGroup({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.Group>) {
return (
<CommandPrimitive.Group
data-slot="command-group"
className={cn(
'text-foreground [&_[cmdk-group-heading]]:text-muted-foreground overflow-hidden p-1 [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1.5 [&_[cmdk-group-heading]]:text-xs [&_[cmdk-group-heading]]:font-medium',
className,
)}
{...props}
/>
)
}
function CommandSeparator({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.Separator>) {
return (
<CommandPrimitive.Separator
data-slot="command-separator"
className={cn('bg-border -mx-1 h-px', className)}
{...props}
/>
)
}
function CommandItem({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.Item>) {
return (
<CommandPrimitive.Item
data-slot="command-item"
className={cn(
"data-[selected=true]:bg-accent data-[selected=true]:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className,
)}
{...props}
/>
)
}
function CommandShortcut({
className,
...props
}: React.ComponentProps<'span'>) {
return (
<span
data-slot="command-shortcut"
className={cn(
'text-muted-foreground ml-auto text-xs tracking-widest',
className,
)}
{...props}
/>
)
}
export {
Command,
CommandDialog,
CommandInput,
CommandList,
CommandEmpty,
CommandGroup,
CommandItem,
CommandShortcut,
CommandSeparator,
}
+252
View File
@@ -0,0 +1,252 @@
'use client'
import * as React from 'react'
import * as ContextMenuPrimitive from '@radix-ui/react-context-menu'
import { CheckIcon, ChevronRightIcon, CircleIcon } from 'lucide-react'
import { cn } from '@/lib/utils'
function ContextMenu({
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Root>) {
return <ContextMenuPrimitive.Root data-slot="context-menu" {...props} />
}
function ContextMenuTrigger({
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Trigger>) {
return (
<ContextMenuPrimitive.Trigger data-slot="context-menu-trigger" {...props} />
)
}
function ContextMenuGroup({
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Group>) {
return (
<ContextMenuPrimitive.Group data-slot="context-menu-group" {...props} />
)
}
function ContextMenuPortal({
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Portal>) {
return (
<ContextMenuPrimitive.Portal data-slot="context-menu-portal" {...props} />
)
}
function ContextMenuSub({
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Sub>) {
return <ContextMenuPrimitive.Sub data-slot="context-menu-sub" {...props} />
}
function ContextMenuRadioGroup({
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.RadioGroup>) {
return (
<ContextMenuPrimitive.RadioGroup
data-slot="context-menu-radio-group"
{...props}
/>
)
}
function ContextMenuSubTrigger({
className,
inset,
children,
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.SubTrigger> & {
inset?: boolean
}) {
return (
<ContextMenuPrimitive.SubTrigger
data-slot="context-menu-sub-trigger"
data-inset={inset}
className={cn(
"focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground flex cursor-default items-center rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className,
)}
{...props}
>
{children}
<ChevronRightIcon className="ml-auto" />
</ContextMenuPrimitive.SubTrigger>
)
}
function ContextMenuSubContent({
className,
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.SubContent>) {
return (
<ContextMenuPrimitive.SubContent
data-slot="context-menu-sub-content"
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-context-menu-content-transform-origin) overflow-hidden rounded-md border p-1 shadow-lg',
className,
)}
{...props}
/>
)
}
function ContextMenuContent({
className,
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Content>) {
return (
<ContextMenuPrimitive.Portal>
<ContextMenuPrimitive.Content
data-slot="context-menu-content"
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-context-menu-content-available-height) min-w-[8rem] origin-(--radix-context-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border p-1 shadow-md',
className,
)}
{...props}
/>
</ContextMenuPrimitive.Portal>
)
}
function ContextMenuItem({
className,
inset,
variant = 'default',
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Item> & {
inset?: boolean
variant?: 'default' | 'destructive'
}) {
return (
<ContextMenuPrimitive.Item
data-slot="context-menu-item"
data-inset={inset}
data-variant={variant}
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",
className,
)}
{...props}
/>
)
}
function ContextMenuCheckboxItem({
className,
children,
checked,
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.CheckboxItem>) {
return (
<ContextMenuPrimitive.CheckboxItem
data-slot="context-menu-checkbox-item"
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",
className,
)}
checked={checked}
{...props}
>
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
<ContextMenuPrimitive.ItemIndicator>
<CheckIcon className="size-4" />
</ContextMenuPrimitive.ItemIndicator>
</span>
{children}
</ContextMenuPrimitive.CheckboxItem>
)
}
function ContextMenuRadioItem({
className,
children,
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.RadioItem>) {
return (
<ContextMenuPrimitive.RadioItem
data-slot="context-menu-radio-item"
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",
className,
)}
{...props}
>
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
<ContextMenuPrimitive.ItemIndicator>
<CircleIcon className="size-2 fill-current" />
</ContextMenuPrimitive.ItemIndicator>
</span>
{children}
</ContextMenuPrimitive.RadioItem>
)
}
function ContextMenuLabel({
className,
inset,
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Label> & {
inset?: boolean
}) {
return (
<ContextMenuPrimitive.Label
data-slot="context-menu-label"
data-inset={inset}
className={cn(
'text-foreground px-2 py-1.5 text-sm font-medium data-[inset]:pl-8',
className,
)}
{...props}
/>
)
}
function ContextMenuSeparator({
className,
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Separator>) {
return (
<ContextMenuPrimitive.Separator
data-slot="context-menu-separator"
className={cn('bg-border -mx-1 my-1 h-px', className)}
{...props}
/>
)
}
function ContextMenuShortcut({
className,
...props
}: React.ComponentProps<'span'>) {
return (
<span
data-slot="context-menu-shortcut"
className={cn(
'text-muted-foreground ml-auto text-xs tracking-widest',
className,
)}
{...props}
/>
)
}
export {
ContextMenu,
ContextMenuTrigger,
ContextMenuContent,
ContextMenuItem,
ContextMenuCheckboxItem,
ContextMenuRadioItem,
ContextMenuLabel,
ContextMenuSeparator,
ContextMenuShortcut,
ContextMenuGroup,
ContextMenuPortal,
ContextMenuSub,
ContextMenuSubContent,
ContextMenuSubTrigger,
ContextMenuRadioGroup,
}
+143
View File
@@ -0,0 +1,143 @@
'use client'
import * as React from 'react'
import * as DialogPrimitive from '@radix-ui/react-dialog'
import { XIcon } from 'lucide-react'
import { cn } from '@/lib/utils'
function Dialog({
...props
}: React.ComponentProps<typeof DialogPrimitive.Root>) {
return <DialogPrimitive.Root data-slot="dialog" {...props} />
}
function DialogTrigger({
...props
}: React.ComponentProps<typeof DialogPrimitive.Trigger>) {
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />
}
function DialogPortal({
...props
}: React.ComponentProps<typeof DialogPrimitive.Portal>) {
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />
}
function DialogClose({
...props
}: React.ComponentProps<typeof DialogPrimitive.Close>) {
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />
}
function DialogOverlay({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Overlay>) {
return (
<DialogPrimitive.Overlay
data-slot="dialog-overlay"
className={cn(
'data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50',
className,
)}
{...props}
/>
)
}
function DialogContent({
className,
children,
showCloseButton = true,
...props
}: React.ComponentProps<typeof DialogPrimitive.Content> & {
showCloseButton?: boolean
}) {
return (
<DialogPortal data-slot="dialog-portal">
<DialogOverlay />
<DialogPrimitive.Content
data-slot="dialog-content"
className={cn(
'bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg',
className,
)}
{...props}
>
{children}
{showCloseButton && (
<DialogPrimitive.Close
data-slot="dialog-close"
className="ring-offset-background focus:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4"
>
<XIcon />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
)}
</DialogPrimitive.Content>
</DialogPortal>
)
}
function DialogHeader({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot="dialog-header"
className={cn('flex flex-col gap-2 text-center sm:text-left', className)}
{...props}
/>
)
}
function DialogFooter({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot="dialog-footer"
className={cn(
'flex flex-col-reverse gap-2 sm:flex-row sm:justify-end',
className,
)}
{...props}
/>
)
}
function DialogTitle({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Title>) {
return (
<DialogPrimitive.Title
data-slot="dialog-title"
className={cn('text-lg leading-none font-semibold', className)}
{...props}
/>
)
}
function DialogDescription({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Description>) {
return (
<DialogPrimitive.Description
data-slot="dialog-description"
className={cn('text-muted-foreground text-sm', className)}
{...props}
/>
)
}
export {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogOverlay,
DialogPortal,
DialogTitle,
DialogTrigger,
}
+135
View File
@@ -0,0 +1,135 @@
'use client'
import * as React from 'react'
import { Drawer as DrawerPrimitive } from 'vaul'
import { cn } from '@/lib/utils'
function Drawer({
...props
}: React.ComponentProps<typeof DrawerPrimitive.Root>) {
return <DrawerPrimitive.Root data-slot="drawer" {...props} />
}
function DrawerTrigger({
...props
}: React.ComponentProps<typeof DrawerPrimitive.Trigger>) {
return <DrawerPrimitive.Trigger data-slot="drawer-trigger" {...props} />
}
function DrawerPortal({
...props
}: React.ComponentProps<typeof DrawerPrimitive.Portal>) {
return <DrawerPrimitive.Portal data-slot="drawer-portal" {...props} />
}
function DrawerClose({
...props
}: React.ComponentProps<typeof DrawerPrimitive.Close>) {
return <DrawerPrimitive.Close data-slot="drawer-close" {...props} />
}
function DrawerOverlay({
className,
...props
}: React.ComponentProps<typeof DrawerPrimitive.Overlay>) {
return (
<DrawerPrimitive.Overlay
data-slot="drawer-overlay"
className={cn(
'data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50',
className,
)}
{...props}
/>
)
}
function DrawerContent({
className,
children,
...props
}: React.ComponentProps<typeof DrawerPrimitive.Content>) {
return (
<DrawerPortal data-slot="drawer-portal">
<DrawerOverlay />
<DrawerPrimitive.Content
data-slot="drawer-content"
className={cn(
'group/drawer-content bg-background fixed z-50 flex h-auto flex-col',
'data-[vaul-drawer-direction=top]:inset-x-0 data-[vaul-drawer-direction=top]:top-0 data-[vaul-drawer-direction=top]:mb-24 data-[vaul-drawer-direction=top]:max-h-[80vh] data-[vaul-drawer-direction=top]:rounded-b-lg data-[vaul-drawer-direction=top]:border-b',
'data-[vaul-drawer-direction=bottom]:inset-x-0 data-[vaul-drawer-direction=bottom]:bottom-0 data-[vaul-drawer-direction=bottom]:mt-24 data-[vaul-drawer-direction=bottom]:max-h-[80vh] data-[vaul-drawer-direction=bottom]:rounded-t-lg data-[vaul-drawer-direction=bottom]:border-t',
'data-[vaul-drawer-direction=right]:inset-y-0 data-[vaul-drawer-direction=right]:right-0 data-[vaul-drawer-direction=right]:w-3/4 data-[vaul-drawer-direction=right]:border-l data-[vaul-drawer-direction=right]:sm:max-w-sm',
'data-[vaul-drawer-direction=left]:inset-y-0 data-[vaul-drawer-direction=left]:left-0 data-[vaul-drawer-direction=left]:w-3/4 data-[vaul-drawer-direction=left]:border-r data-[vaul-drawer-direction=left]:sm:max-w-sm',
className,
)}
{...props}
>
<div className="bg-muted mx-auto mt-4 hidden h-2 w-[100px] shrink-0 rounded-full group-data-[vaul-drawer-direction=bottom]/drawer-content:block" />
{children}
</DrawerPrimitive.Content>
</DrawerPortal>
)
}
function DrawerHeader({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot="drawer-header"
className={cn(
'flex flex-col gap-0.5 p-4 group-data-[vaul-drawer-direction=bottom]/drawer-content:text-center group-data-[vaul-drawer-direction=top]/drawer-content:text-center md:gap-1.5 md:text-left',
className,
)}
{...props}
/>
)
}
function DrawerFooter({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot="drawer-footer"
className={cn('mt-auto flex flex-col gap-2 p-4', className)}
{...props}
/>
)
}
function DrawerTitle({
className,
...props
}: React.ComponentProps<typeof DrawerPrimitive.Title>) {
return (
<DrawerPrimitive.Title
data-slot="drawer-title"
className={cn('text-foreground font-semibold', className)}
{...props}
/>
)
}
function DrawerDescription({
className,
...props
}: React.ComponentProps<typeof DrawerPrimitive.Description>) {
return (
<DrawerPrimitive.Description
data-slot="drawer-description"
className={cn('text-muted-foreground text-sm', className)}
{...props}
/>
)
}
export {
Drawer,
DrawerPortal,
DrawerOverlay,
DrawerTrigger,
DrawerClose,
DrawerContent,
DrawerHeader,
DrawerFooter,
DrawerTitle,
DrawerDescription,
}
+257
View File
@@ -0,0 +1,257 @@
'use client'
import * as React from 'react'
import * as DropdownMenuPrimitive from '@radix-ui/react-dropdown-menu'
import { CheckIcon, ChevronRightIcon, CircleIcon } from 'lucide-react'
import { cn } from '@/lib/utils'
function DropdownMenu({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Root>) {
return <DropdownMenuPrimitive.Root data-slot="dropdown-menu" {...props} />
}
function DropdownMenuPortal({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Portal>) {
return (
<DropdownMenuPrimitive.Portal data-slot="dropdown-menu-portal" {...props} />
)
}
function DropdownMenuTrigger({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Trigger>) {
return (
<DropdownMenuPrimitive.Trigger
data-slot="dropdown-menu-trigger"
{...props}
/>
)
}
function DropdownMenuContent({
className,
sideOffset = 4,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Content>) {
return (
<DropdownMenuPrimitive.Portal>
<DropdownMenuPrimitive.Content
data-slot="dropdown-menu-content"
sideOffset={sideOffset}
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',
className,
)}
{...props}
/>
</DropdownMenuPrimitive.Portal>
)
}
function DropdownMenuGroup({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Group>) {
return (
<DropdownMenuPrimitive.Group data-slot="dropdown-menu-group" {...props} />
)
}
function DropdownMenuItem({
className,
inset,
variant = 'default',
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Item> & {
inset?: boolean
variant?: 'default' | 'destructive'
}) {
return (
<DropdownMenuPrimitive.Item
data-slot="dropdown-menu-item"
data-inset={inset}
data-variant={variant}
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",
className,
)}
{...props}
/>
)
}
function DropdownMenuCheckboxItem({
className,
children,
checked,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.CheckboxItem>) {
return (
<DropdownMenuPrimitive.CheckboxItem
data-slot="dropdown-menu-checkbox-item"
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",
className,
)}
checked={checked}
{...props}
>
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<CheckIcon className="size-4" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.CheckboxItem>
)
}
function DropdownMenuRadioGroup({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioGroup>) {
return (
<DropdownMenuPrimitive.RadioGroup
data-slot="dropdown-menu-radio-group"
{...props}
/>
)
}
function DropdownMenuRadioItem({
className,
children,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioItem>) {
return (
<DropdownMenuPrimitive.RadioItem
data-slot="dropdown-menu-radio-item"
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",
className,
)}
{...props}
>
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<CircleIcon className="size-2 fill-current" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.RadioItem>
)
}
function DropdownMenuLabel({
className,
inset,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Label> & {
inset?: boolean
}) {
return (
<DropdownMenuPrimitive.Label
data-slot="dropdown-menu-label"
data-inset={inset}
className={cn(
'px-2 py-1.5 text-sm font-medium data-[inset]:pl-8',
className,
)}
{...props}
/>
)
}
function DropdownMenuSeparator({
className,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Separator>) {
return (
<DropdownMenuPrimitive.Separator
data-slot="dropdown-menu-separator"
className={cn('bg-border -mx-1 my-1 h-px', className)}
{...props}
/>
)
}
function DropdownMenuShortcut({
className,
...props
}: React.ComponentProps<'span'>) {
return (
<span
data-slot="dropdown-menu-shortcut"
className={cn(
'text-muted-foreground ml-auto text-xs tracking-widest',
className,
)}
{...props}
/>
)
}
function DropdownMenuSub({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Sub>) {
return <DropdownMenuPrimitive.Sub data-slot="dropdown-menu-sub" {...props} />
}
function DropdownMenuSubTrigger({
className,
inset,
children,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubTrigger> & {
inset?: boolean
}) {
return (
<DropdownMenuPrimitive.SubTrigger
data-slot="dropdown-menu-sub-trigger"
data-inset={inset}
className={cn(
"focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className,
)}
{...props}
>
{children}
<ChevronRightIcon className="ml-auto size-4" />
</DropdownMenuPrimitive.SubTrigger>
)
}
function DropdownMenuSubContent({
className,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubContent>) {
return (
<DropdownMenuPrimitive.SubContent
data-slot="dropdown-menu-sub-content"
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',
className,
)}
{...props}
/>
)
}
export {
DropdownMenu,
DropdownMenuPortal,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuLabel,
DropdownMenuItem,
DropdownMenuCheckboxItem,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuSub,
DropdownMenuSubTrigger,
DropdownMenuSubContent,
}
+104
View File
@@ -0,0 +1,104 @@
import { cva, type VariantProps } from 'class-variance-authority'
import { cn } from '@/lib/utils'
function Empty({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot="empty"
className={cn(
'flex min-w-0 flex-1 flex-col items-center justify-center gap-6 rounded-lg border-dashed p-6 text-center text-balance md:p-12',
className,
)}
{...props}
/>
)
}
function EmptyHeader({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot="empty-header"
className={cn(
'flex max-w-sm flex-col items-center gap-2 text-center',
className,
)}
{...props}
/>
)
}
const emptyMediaVariants = cva(
'flex shrink-0 items-center justify-center mb-2 [&_svg]:pointer-events-none [&_svg]:shrink-0',
{
variants: {
variant: {
default: 'bg-transparent',
icon: "bg-muted text-foreground flex size-10 shrink-0 items-center justify-center rounded-lg [&_svg:not([class*='size-'])]:size-6",
},
},
defaultVariants: {
variant: 'default',
},
},
)
function EmptyMedia({
className,
variant = 'default',
...props
}: React.ComponentProps<'div'> & VariantProps<typeof emptyMediaVariants>) {
return (
<div
data-slot="empty-icon"
data-variant={variant}
className={cn(emptyMediaVariants({ variant, className }))}
{...props}
/>
)
}
function EmptyTitle({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot="empty-title"
className={cn('text-lg font-medium tracking-tight', className)}
{...props}
/>
)
}
function EmptyDescription({ className, ...props }: React.ComponentProps<'p'>) {
return (
<div
data-slot="empty-description"
className={cn(
'text-muted-foreground [&>a:hover]:text-primary text-sm/relaxed [&>a]:underline [&>a]:underline-offset-4',
className,
)}
{...props}
/>
)
}
function EmptyContent({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot="empty-content"
className={cn(
'flex w-full max-w-sm min-w-0 flex-col items-center gap-4 text-sm text-balance',
className,
)}
{...props}
/>
)
}
export {
Empty,
EmptyHeader,
EmptyTitle,
EmptyDescription,
EmptyContent,
EmptyMedia,
}
+244
View File
@@ -0,0 +1,244 @@
'use client'
import { useMemo } from 'react'
import { cva, type VariantProps } from 'class-variance-authority'
import { cn } from '@/lib/utils'
import { Label } from '@/components/ui/label'
import { Separator } from '@/components/ui/separator'
function FieldSet({ className, ...props }: React.ComponentProps<'fieldset'>) {
return (
<fieldset
data-slot="field-set"
className={cn(
'flex flex-col gap-6',
'has-[>[data-slot=checkbox-group]]:gap-3 has-[>[data-slot=radio-group]]:gap-3',
className,
)}
{...props}
/>
)
}
function FieldLegend({
className,
variant = 'legend',
...props
}: React.ComponentProps<'legend'> & { variant?: 'legend' | 'label' }) {
return (
<legend
data-slot="field-legend"
data-variant={variant}
className={cn(
'mb-3 font-medium',
'data-[variant=legend]:text-base',
'data-[variant=label]:text-sm',
className,
)}
{...props}
/>
)
}
function FieldGroup({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot="field-group"
className={cn(
'group/field-group @container/field-group flex w-full flex-col gap-7 data-[slot=checkbox-group]:gap-3 [&>[data-slot=field-group]]:gap-4',
className,
)}
{...props}
/>
)
}
const fieldVariants = cva(
'group/field flex w-full gap-3 data-[invalid=true]:text-destructive',
{
variants: {
orientation: {
vertical: ['flex-col [&>*]:w-full [&>.sr-only]:w-auto'],
horizontal: [
'flex-row items-center',
'[&>[data-slot=field-label]]:flex-auto',
'has-[>[data-slot=field-content]]:items-start has-[>[data-slot=field-content]]:[&>[role=checkbox],[role=radio]]:mt-px',
],
responsive: [
'flex-col [&>*]:w-full [&>.sr-only]:w-auto @md/field-group:flex-row @md/field-group:items-center @md/field-group:[&>*]:w-auto',
'@md/field-group:[&>[data-slot=field-label]]:flex-auto',
'@md/field-group:has-[>[data-slot=field-content]]:items-start @md/field-group:has-[>[data-slot=field-content]]:[&>[role=checkbox],[role=radio]]:mt-px',
],
},
},
defaultVariants: {
orientation: 'vertical',
},
},
)
function Field({
className,
orientation = 'vertical',
...props
}: React.ComponentProps<'div'> & VariantProps<typeof fieldVariants>) {
return (
<div
role="group"
data-slot="field"
data-orientation={orientation}
className={cn(fieldVariants({ orientation }), className)}
{...props}
/>
)
}
function FieldContent({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot="field-content"
className={cn(
'group/field-content flex flex-1 flex-col gap-1.5 leading-snug',
className,
)}
{...props}
/>
)
}
function FieldLabel({
className,
...props
}: React.ComponentProps<typeof Label>) {
return (
<Label
data-slot="field-label"
className={cn(
'group/field-label peer/field-label flex w-fit gap-2 leading-snug group-data-[disabled=true]/field:opacity-50',
'has-[>[data-slot=field]]:w-full has-[>[data-slot=field]]:flex-col has-[>[data-slot=field]]:rounded-md has-[>[data-slot=field]]:border [&>*]:data-[slot=field]:p-4',
'has-data-[state=checked]:bg-primary/5 has-data-[state=checked]:border-primary dark:has-data-[state=checked]:bg-primary/10',
className,
)}
{...props}
/>
)
}
function FieldTitle({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot="field-label"
className={cn(
'flex w-fit items-center gap-2 text-sm leading-snug font-medium group-data-[disabled=true]/field:opacity-50',
className,
)}
{...props}
/>
)
}
function FieldDescription({ className, ...props }: React.ComponentProps<'p'>) {
return (
<p
data-slot="field-description"
className={cn(
'text-muted-foreground text-sm leading-normal font-normal group-has-[[data-orientation=horizontal]]/field:text-balance',
'last:mt-0 nth-last-2:-mt-1 [[data-variant=legend]+&]:-mt-1.5',
'[&>a:hover]:text-primary [&>a]:underline [&>a]:underline-offset-4',
className,
)}
{...props}
/>
)
}
function FieldSeparator({
children,
className,
...props
}: React.ComponentProps<'div'> & {
children?: React.ReactNode
}) {
return (
<div
data-slot="field-separator"
data-content={!!children}
className={cn(
'relative -my-2 h-5 text-sm group-data-[variant=outline]/field-group:-mb-2',
className,
)}
{...props}
>
<Separator className="absolute inset-0 top-1/2" />
{children && (
<span
className="bg-background text-muted-foreground relative mx-auto block w-fit px-2"
data-slot="field-separator-content"
>
{children}
</span>
)}
</div>
)
}
function FieldError({
className,
children,
errors,
...props
}: React.ComponentProps<'div'> & {
errors?: Array<{ message?: string } | undefined>
}) {
const content = useMemo(() => {
if (children) {
return children
}
if (!errors) {
return null
}
if (errors.length === 1 && errors[0]?.message) {
return errors[0].message
}
return (
<ul className="ml-4 flex list-disc flex-col gap-1">
{errors.map(
(error, index) =>
error?.message && <li key={index}>{error.message}</li>,
)}
</ul>
)
}, [children, errors])
if (!content) {
return null
}
return (
<div
role="alert"
data-slot="field-error"
className={cn('text-destructive text-sm font-normal', className)}
{...props}
>
{content}
</div>
)
}
export {
Field,
FieldLabel,
FieldDescription,
FieldError,
FieldGroup,
FieldLegend,
FieldSeparator,
FieldSet,
FieldContent,
FieldTitle,
}
+167
View File
@@ -0,0 +1,167 @@
'use client'
import * as React from 'react'
import * as LabelPrimitive from '@radix-ui/react-label'
import { Slot } from '@radix-ui/react-slot'
import {
Controller,
FormProvider,
useFormContext,
useFormState,
type ControllerProps,
type FieldPath,
type FieldValues,
} from 'react-hook-form'
import { cn } from '@/lib/utils'
import { Label } from '@/components/ui/label'
const Form = FormProvider
type FormFieldContextValue<
TFieldValues extends FieldValues = FieldValues,
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
> = {
name: TName
}
const FormFieldContext = React.createContext<FormFieldContextValue>(
{} as FormFieldContextValue,
)
const FormField = <
TFieldValues extends FieldValues = FieldValues,
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
>({
...props
}: ControllerProps<TFieldValues, TName>) => {
return (
<FormFieldContext.Provider value={{ name: props.name }}>
<Controller {...props} />
</FormFieldContext.Provider>
)
}
const useFormField = () => {
const fieldContext = React.useContext(FormFieldContext)
const itemContext = React.useContext(FormItemContext)
const { getFieldState } = useFormContext()
const formState = useFormState({ name: fieldContext.name })
const fieldState = getFieldState(fieldContext.name, formState)
if (!fieldContext) {
throw new Error('useFormField should be used within <FormField>')
}
const { id } = itemContext
return {
id,
name: fieldContext.name,
formItemId: `${id}-form-item`,
formDescriptionId: `${id}-form-item-description`,
formMessageId: `${id}-form-item-message`,
...fieldState,
}
}
type FormItemContextValue = {
id: string
}
const FormItemContext = React.createContext<FormItemContextValue>(
{} as FormItemContextValue,
)
function FormItem({ className, ...props }: React.ComponentProps<'div'>) {
const id = React.useId()
return (
<FormItemContext.Provider value={{ id }}>
<div
data-slot="form-item"
className={cn('grid gap-2', className)}
{...props}
/>
</FormItemContext.Provider>
)
}
function FormLabel({
className,
...props
}: React.ComponentProps<typeof LabelPrimitive.Root>) {
const { error, formItemId } = useFormField()
return (
<Label
data-slot="form-label"
data-error={!!error}
className={cn('data-[error=true]:text-destructive', className)}
htmlFor={formItemId}
{...props}
/>
)
}
function FormControl({ ...props }: React.ComponentProps<typeof Slot>) {
const { error, formItemId, formDescriptionId, formMessageId } = useFormField()
return (
<Slot
data-slot="form-control"
id={formItemId}
aria-describedby={
!error
? `${formDescriptionId}`
: `${formDescriptionId} ${formMessageId}`
}
aria-invalid={!!error}
{...props}
/>
)
}
function FormDescription({ className, ...props }: React.ComponentProps<'p'>) {
const { formDescriptionId } = useFormField()
return (
<p
data-slot="form-description"
id={formDescriptionId}
className={cn('text-muted-foreground text-sm', className)}
{...props}
/>
)
}
function FormMessage({ className, ...props }: React.ComponentProps<'p'>) {
const { error, formMessageId } = useFormField()
const body = error ? String(error?.message ?? '') : props.children
if (!body) {
return null
}
return (
<p
data-slot="form-message"
id={formMessageId}
className={cn('text-destructive text-sm', className)}
{...props}
>
{body}
</p>
)
}
export {
useFormField,
Form,
FormItem,
FormLabel,
FormControl,
FormDescription,
FormMessage,
FormField,
}
+44
View File
@@ -0,0 +1,44 @@
'use client'
import * as React from 'react'
import * as HoverCardPrimitive from '@radix-ui/react-hover-card'
import { cn } from '@/lib/utils'
function HoverCard({
...props
}: React.ComponentProps<typeof HoverCardPrimitive.Root>) {
return <HoverCardPrimitive.Root data-slot="hover-card" {...props} />
}
function HoverCardTrigger({
...props
}: React.ComponentProps<typeof HoverCardPrimitive.Trigger>) {
return (
<HoverCardPrimitive.Trigger data-slot="hover-card-trigger" {...props} />
)
}
function HoverCardContent({
className,
align = 'center',
sideOffset = 4,
...props
}: React.ComponentProps<typeof HoverCardPrimitive.Content>) {
return (
<HoverCardPrimitive.Portal data-slot="hover-card-portal">
<HoverCardPrimitive.Content
data-slot="hover-card-content"
align={align}
sideOffset={sideOffset}
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 w-64 origin-(--radix-hover-card-content-transform-origin) rounded-md border p-4 shadow-md outline-hidden',
className,
)}
{...props}
/>
</HoverCardPrimitive.Portal>
)
}
export { HoverCard, HoverCardTrigger, HoverCardContent }
+169
View File
@@ -0,0 +1,169 @@
'use client'
import { cva, type VariantProps } from 'class-variance-authority'
import { cn } from '@/lib/utils'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Textarea } from '@/components/ui/textarea'
function InputGroup({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot="input-group"
role="group"
className={cn(
'group/input-group border-input dark:bg-input/30 relative flex w-full items-center rounded-md border shadow-xs transition-[color,box-shadow] outline-none',
'h-9 has-[>textarea]:h-auto',
// Variants based on alignment.
'has-[>[data-align=inline-start]]:[&>input]:pl-2',
'has-[>[data-align=inline-end]]:[&>input]:pr-2',
'has-[>[data-align=block-start]]:h-auto has-[>[data-align=block-start]]:flex-col has-[>[data-align=block-start]]:[&>input]:pb-3',
'has-[>[data-align=block-end]]:h-auto has-[>[data-align=block-end]]:flex-col has-[>[data-align=block-end]]:[&>input]:pt-3',
// Focus state.
'has-[[data-slot=input-group-control]:focus-visible]:border-ring has-[[data-slot=input-group-control]:focus-visible]:ring-ring/50 has-[[data-slot=input-group-control]:focus-visible]:ring-[3px]',
// Error state.
'has-[[data-slot][aria-invalid=true]]:ring-destructive/20 has-[[data-slot][aria-invalid=true]]:border-destructive dark:has-[[data-slot][aria-invalid=true]]:ring-destructive/40',
className,
)}
{...props}
/>
)
}
const inputGroupAddonVariants = cva(
"text-muted-foreground flex h-auto cursor-text items-center justify-center gap-2 py-1.5 text-sm font-medium select-none [&>svg:not([class*='size-'])]:size-4 [&>kbd]:rounded-[calc(var(--radius)-5px)] group-data-[disabled=true]/input-group:opacity-50",
{
variants: {
align: {
'inline-start':
'order-first pl-3 has-[>button]:ml-[-0.45rem] has-[>kbd]:ml-[-0.35rem]',
'inline-end':
'order-last pr-3 has-[>button]:mr-[-0.4rem] has-[>kbd]:mr-[-0.35rem]',
'block-start':
'order-first w-full justify-start px-3 pt-3 [.border-b]:pb-3 group-has-[>input]/input-group:pt-2.5',
'block-end':
'order-last w-full justify-start px-3 pb-3 [.border-t]:pt-3 group-has-[>input]/input-group:pb-2.5',
},
},
defaultVariants: {
align: 'inline-start',
},
},
)
function InputGroupAddon({
className,
align = 'inline-start',
...props
}: React.ComponentProps<'div'> & VariantProps<typeof inputGroupAddonVariants>) {
return (
<div
role="group"
data-slot="input-group-addon"
data-align={align}
className={cn(inputGroupAddonVariants({ align }), className)}
onClick={(e) => {
if ((e.target as HTMLElement).closest('button')) {
return
}
e.currentTarget.parentElement?.querySelector('input')?.focus()
}}
{...props}
/>
)
}
const inputGroupButtonVariants = cva(
'text-sm shadow-none flex gap-2 items-center',
{
variants: {
size: {
xs: "h-6 gap-1 px-2 rounded-[calc(var(--radius)-5px)] [&>svg:not([class*='size-'])]:size-3.5 has-[>svg]:px-2",
sm: 'h-8 px-2.5 gap-1.5 rounded-md has-[>svg]:px-2.5',
'icon-xs':
'size-6 rounded-[calc(var(--radius)-5px)] p-0 has-[>svg]:p-0',
'icon-sm': 'size-8 p-0 has-[>svg]:p-0',
},
},
defaultVariants: {
size: 'xs',
},
},
)
function InputGroupButton({
className,
type = 'button',
variant = 'ghost',
size = 'xs',
...props
}: Omit<React.ComponentProps<typeof Button>, 'size'> &
VariantProps<typeof inputGroupButtonVariants>) {
return (
<Button
type={type}
data-size={size}
variant={variant}
className={cn(inputGroupButtonVariants({ size }), className)}
{...props}
/>
)
}
function InputGroupText({ className, ...props }: React.ComponentProps<'span'>) {
return (
<span
className={cn(
"text-muted-foreground flex items-center gap-2 text-sm [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4",
className,
)}
{...props}
/>
)
}
function InputGroupInput({
className,
...props
}: React.ComponentProps<'input'>) {
return (
<Input
data-slot="input-group-control"
className={cn(
'flex-1 rounded-none border-0 bg-transparent shadow-none focus-visible:ring-0 dark:bg-transparent',
className,
)}
{...props}
/>
)
}
function InputGroupTextarea({
className,
...props
}: React.ComponentProps<'textarea'>) {
return (
<Textarea
data-slot="input-group-control"
className={cn(
'flex-1 resize-none rounded-none border-0 bg-transparent py-3 shadow-none focus-visible:ring-0 dark:bg-transparent',
className,
)}
{...props}
/>
)
}
export {
InputGroup,
InputGroupAddon,
InputGroupButton,
InputGroupText,
InputGroupInput,
InputGroupTextarea,
}
+77
View File
@@ -0,0 +1,77 @@
'use client'
import * as React from 'react'
import { OTPInput, OTPInputContext } from 'input-otp'
import { MinusIcon } from 'lucide-react'
import { cn } from '@/lib/utils'
function InputOTP({
className,
containerClassName,
...props
}: React.ComponentProps<typeof OTPInput> & {
containerClassName?: string
}) {
return (
<OTPInput
data-slot="input-otp"
containerClassName={cn(
'flex items-center gap-2 has-disabled:opacity-50',
containerClassName,
)}
className={cn('disabled:cursor-not-allowed', className)}
{...props}
/>
)
}
function InputOTPGroup({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot="input-otp-group"
className={cn('flex items-center', className)}
{...props}
/>
)
}
function InputOTPSlot({
index,
className,
...props
}: React.ComponentProps<'div'> & {
index: number
}) {
const inputOTPContext = React.useContext(OTPInputContext)
const { char, hasFakeCaret, isActive } = inputOTPContext?.slots[index] ?? {}
return (
<div
data-slot="input-otp-slot"
data-active={isActive}
className={cn(
'data-[active=true]:border-ring data-[active=true]:ring-ring/50 data-[active=true]:aria-invalid:ring-destructive/20 dark:data-[active=true]:aria-invalid:ring-destructive/40 aria-invalid:border-destructive data-[active=true]:aria-invalid:border-destructive dark:bg-input/30 border-input relative flex h-9 w-9 items-center justify-center border-y border-r text-sm shadow-xs transition-all outline-none first:rounded-l-md first:border-l last:rounded-r-md data-[active=true]:z-10 data-[active=true]:ring-[3px]',
className,
)}
{...props}
>
{char}
{hasFakeCaret && (
<div className="pointer-events-none absolute inset-0 flex items-center justify-center">
<div className="animate-caret-blink bg-foreground h-4 w-px duration-1000" />
</div>
)}
</div>
)
}
function InputOTPSeparator({ ...props }: React.ComponentProps<'div'>) {
return (
<div data-slot="input-otp-separator" role="separator" {...props}>
<MinusIcon />
</div>
)
}
export { InputOTP, InputOTPGroup, InputOTPSlot, InputOTPSeparator }
+21
View File
@@ -0,0 +1,21 @@
import * as React from 'react'
import { cn } from '@/lib/utils'
function Input({ className, type, ...props }: React.ComponentProps<'input'>) {
return (
<input
type={type}
data-slot="input"
className={cn(
'file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input 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]',
'aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive',
className,
)}
{...props}
/>
)
}
export { Input }
+193
View File
@@ -0,0 +1,193 @@
import * as React from 'react'
import { Slot } from '@radix-ui/react-slot'
import { cva, type VariantProps } from 'class-variance-authority'
import { cn } from '@/lib/utils'
import { Separator } from '@/components/ui/separator'
function ItemGroup({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
role="list"
data-slot="item-group"
className={cn('group/item-group flex flex-col', className)}
{...props}
/>
)
}
function ItemSeparator({
className,
...props
}: React.ComponentProps<typeof Separator>) {
return (
<Separator
data-slot="item-separator"
orientation="horizontal"
className={cn('my-0', className)}
{...props}
/>
)
}
const itemVariants = cva(
'group/item flex items-center border border-transparent text-sm rounded-md transition-colors [a&]:hover:bg-accent/50 [a&]:transition-colors duration-100 flex-wrap outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]',
{
variants: {
variant: {
default: 'bg-transparent',
outline: 'border-border',
muted: 'bg-muted/50',
},
size: {
default: 'p-4 gap-4 ',
sm: 'py-3 px-4 gap-2.5',
},
},
defaultVariants: {
variant: 'default',
size: 'default',
},
},
)
function Item({
className,
variant = 'default',
size = 'default',
asChild = false,
...props
}: React.ComponentProps<'div'> &
VariantProps<typeof itemVariants> & { asChild?: boolean }) {
const Comp = asChild ? Slot : 'div'
return (
<Comp
data-slot="item"
data-variant={variant}
data-size={size}
className={cn(itemVariants({ variant, size, className }))}
{...props}
/>
)
}
const itemMediaVariants = cva(
'flex shrink-0 items-center justify-center gap-2 group-has-[[data-slot=item-description]]/item:self-start [&_svg]:pointer-events-none group-has-[[data-slot=item-description]]/item:translate-y-0.5',
{
variants: {
variant: {
default: 'bg-transparent',
icon: "size-8 border rounded-sm bg-muted [&_svg:not([class*='size-'])]:size-4",
image:
'size-10 rounded-sm overflow-hidden [&_img]:size-full [&_img]:object-cover',
},
},
defaultVariants: {
variant: 'default',
},
},
)
function ItemMedia({
className,
variant = 'default',
...props
}: React.ComponentProps<'div'> & VariantProps<typeof itemMediaVariants>) {
return (
<div
data-slot="item-media"
data-variant={variant}
className={cn(itemMediaVariants({ variant, className }))}
{...props}
/>
)
}
function ItemContent({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot="item-content"
className={cn(
'flex flex-1 flex-col gap-1 [&+[data-slot=item-content]]:flex-none',
className,
)}
{...props}
/>
)
}
function ItemTitle({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot="item-title"
className={cn(
'flex w-fit items-center gap-2 text-sm leading-snug font-medium',
className,
)}
{...props}
/>
)
}
function ItemDescription({ className, ...props }: React.ComponentProps<'p'>) {
return (
<p
data-slot="item-description"
className={cn(
'text-muted-foreground line-clamp-2 text-sm leading-normal font-normal text-balance',
'[&>a:hover]:text-primary [&>a]:underline [&>a]:underline-offset-4',
className,
)}
{...props}
/>
)
}
function ItemActions({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot="item-actions"
className={cn('flex items-center gap-2', className)}
{...props}
/>
)
}
function ItemHeader({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot="item-header"
className={cn(
'flex basis-full items-center justify-between gap-2',
className,
)}
{...props}
/>
)
}
function ItemFooter({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot="item-footer"
className={cn(
'flex basis-full items-center justify-between gap-2',
className,
)}
{...props}
/>
)
}
export {
Item,
ItemMedia,
ItemContent,
ItemActions,
ItemGroup,
ItemSeparator,
ItemTitle,
ItemDescription,
ItemHeader,
ItemFooter,
}
+28
View File
@@ -0,0 +1,28 @@
import { cn } from '@/lib/utils'
function Kbd({ className, ...props }: React.ComponentProps<'kbd'>) {
return (
<kbd
data-slot="kbd"
className={cn(
'bg-muted w-fit text-muted-foreground pointer-events-none inline-flex h-5 min-w-5 items-center justify-center gap-1 rounded-sm px-1 font-sans text-xs font-medium select-none',
"[&_svg:not([class*='size-'])]:size-3",
'[[data-slot=tooltip-content]_&]:bg-background/20 [[data-slot=tooltip-content]_&]:text-background dark:[[data-slot=tooltip-content]_&]:bg-background/10',
className,
)}
{...props}
/>
)
}
function KbdGroup({ className, ...props }: React.ComponentProps<'div'>) {
return (
<kbd
data-slot="kbd-group"
className={cn('inline-flex items-center gap-1', className)}
{...props}
/>
)
}
export { Kbd, KbdGroup }
+24
View File
@@ -0,0 +1,24 @@
'use client'
import * as React from 'react'
import * as LabelPrimitive from '@radix-ui/react-label'
import { cn } from '@/lib/utils'
function Label({
className,
...props
}: React.ComponentProps<typeof LabelPrimitive.Root>) {
return (
<LabelPrimitive.Root
data-slot="label"
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',
className,
)}
{...props}
/>
)
}
export { Label }
+276
View File
@@ -0,0 +1,276 @@
'use client'
import * as React from 'react'
import * as MenubarPrimitive from '@radix-ui/react-menubar'
import { CheckIcon, ChevronRightIcon, CircleIcon } from 'lucide-react'
import { cn } from '@/lib/utils'
function Menubar({
className,
...props
}: React.ComponentProps<typeof MenubarPrimitive.Root>) {
return (
<MenubarPrimitive.Root
data-slot="menubar"
className={cn(
'bg-background flex h-9 items-center gap-1 rounded-md border p-1 shadow-xs',
className,
)}
{...props}
/>
)
}
function MenubarMenu({
...props
}: React.ComponentProps<typeof MenubarPrimitive.Menu>) {
return <MenubarPrimitive.Menu data-slot="menubar-menu" {...props} />
}
function MenubarGroup({
...props
}: React.ComponentProps<typeof MenubarPrimitive.Group>) {
return <MenubarPrimitive.Group data-slot="menubar-group" {...props} />
}
function MenubarPortal({
...props
}: React.ComponentProps<typeof MenubarPrimitive.Portal>) {
return <MenubarPrimitive.Portal data-slot="menubar-portal" {...props} />
}
function MenubarRadioGroup({
...props
}: React.ComponentProps<typeof MenubarPrimitive.RadioGroup>) {
return (
<MenubarPrimitive.RadioGroup data-slot="menubar-radio-group" {...props} />
)
}
function MenubarTrigger({
className,
...props
}: React.ComponentProps<typeof MenubarPrimitive.Trigger>) {
return (
<MenubarPrimitive.Trigger
data-slot="menubar-trigger"
className={cn(
'focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground flex items-center rounded-sm px-2 py-1 text-sm font-medium outline-hidden select-none',
className,
)}
{...props}
/>
)
}
function MenubarContent({
className,
align = 'start',
alignOffset = -4,
sideOffset = 8,
...props
}: React.ComponentProps<typeof MenubarPrimitive.Content>) {
return (
<MenubarPortal>
<MenubarPrimitive.Content
data-slot="menubar-content"
align={align}
alignOffset={alignOffset}
sideOffset={sideOffset}
className={cn(
'bg-popover text-popover-foreground data-[state=open]:animate-in 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-[12rem] origin-(--radix-menubar-content-transform-origin) overflow-hidden rounded-md border p-1 shadow-md',
className,
)}
{...props}
/>
</MenubarPortal>
)
}
function MenubarItem({
className,
inset,
variant = 'default',
...props
}: React.ComponentProps<typeof MenubarPrimitive.Item> & {
inset?: boolean
variant?: 'default' | 'destructive'
}) {
return (
<MenubarPrimitive.Item
data-slot="menubar-item"
data-inset={inset}
data-variant={variant}
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",
className,
)}
{...props}
/>
)
}
function MenubarCheckboxItem({
className,
children,
checked,
...props
}: React.ComponentProps<typeof MenubarPrimitive.CheckboxItem>) {
return (
<MenubarPrimitive.CheckboxItem
data-slot="menubar-checkbox-item"
className={cn(
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-xs 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,
)}
checked={checked}
{...props}
>
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
<MenubarPrimitive.ItemIndicator>
<CheckIcon className="size-4" />
</MenubarPrimitive.ItemIndicator>
</span>
{children}
</MenubarPrimitive.CheckboxItem>
)
}
function MenubarRadioItem({
className,
children,
...props
}: React.ComponentProps<typeof MenubarPrimitive.RadioItem>) {
return (
<MenubarPrimitive.RadioItem
data-slot="menubar-radio-item"
className={cn(
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-xs 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,
)}
{...props}
>
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
<MenubarPrimitive.ItemIndicator>
<CircleIcon className="size-2 fill-current" />
</MenubarPrimitive.ItemIndicator>
</span>
{children}
</MenubarPrimitive.RadioItem>
)
}
function MenubarLabel({
className,
inset,
...props
}: React.ComponentProps<typeof MenubarPrimitive.Label> & {
inset?: boolean
}) {
return (
<MenubarPrimitive.Label
data-slot="menubar-label"
data-inset={inset}
className={cn(
'px-2 py-1.5 text-sm font-medium data-[inset]:pl-8',
className,
)}
{...props}
/>
)
}
function MenubarSeparator({
className,
...props
}: React.ComponentProps<typeof MenubarPrimitive.Separator>) {
return (
<MenubarPrimitive.Separator
data-slot="menubar-separator"
className={cn('bg-border -mx-1 my-1 h-px', className)}
{...props}
/>
)
}
function MenubarShortcut({
className,
...props
}: React.ComponentProps<'span'>) {
return (
<span
data-slot="menubar-shortcut"
className={cn(
'text-muted-foreground ml-auto text-xs tracking-widest',
className,
)}
{...props}
/>
)
}
function MenubarSub({
...props
}: React.ComponentProps<typeof MenubarPrimitive.Sub>) {
return <MenubarPrimitive.Sub data-slot="menubar-sub" {...props} />
}
function MenubarSubTrigger({
className,
inset,
children,
...props
}: React.ComponentProps<typeof MenubarPrimitive.SubTrigger> & {
inset?: boolean
}) {
return (
<MenubarPrimitive.SubTrigger
data-slot="menubar-sub-trigger"
data-inset={inset}
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-none select-none data-[inset]:pl-8',
className,
)}
{...props}
>
{children}
<ChevronRightIcon className="ml-auto h-4 w-4" />
</MenubarPrimitive.SubTrigger>
)
}
function MenubarSubContent({
className,
...props
}: React.ComponentProps<typeof MenubarPrimitive.SubContent>) {
return (
<MenubarPrimitive.SubContent
data-slot="menubar-sub-content"
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-menubar-content-transform-origin) overflow-hidden rounded-md border p-1 shadow-lg',
className,
)}
{...props}
/>
)
}
export {
Menubar,
MenubarPortal,
MenubarMenu,
MenubarTrigger,
MenubarContent,
MenubarGroup,
MenubarSeparator,
MenubarLabel,
MenubarItem,
MenubarShortcut,
MenubarCheckboxItem,
MenubarRadioGroup,
MenubarRadioItem,
MenubarSub,
MenubarSubTrigger,
MenubarSubContent,
}
+166
View File
@@ -0,0 +1,166 @@
import * as React from 'react'
import * as NavigationMenuPrimitive from '@radix-ui/react-navigation-menu'
import { cva } from 'class-variance-authority'
import { ChevronDownIcon } from 'lucide-react'
import { cn } from '@/lib/utils'
function NavigationMenu({
className,
children,
viewport = true,
...props
}: React.ComponentProps<typeof NavigationMenuPrimitive.Root> & {
viewport?: boolean
}) {
return (
<NavigationMenuPrimitive.Root
data-slot="navigation-menu"
data-viewport={viewport}
className={cn(
'group/navigation-menu relative flex max-w-max flex-1 items-center justify-center',
className,
)}
{...props}
>
{children}
{viewport && <NavigationMenuViewport />}
</NavigationMenuPrimitive.Root>
)
}
function NavigationMenuList({
className,
...props
}: React.ComponentProps<typeof NavigationMenuPrimitive.List>) {
return (
<NavigationMenuPrimitive.List
data-slot="navigation-menu-list"
className={cn(
'group flex flex-1 list-none items-center justify-center gap-1',
className,
)}
{...props}
/>
)
}
function NavigationMenuItem({
className,
...props
}: React.ComponentProps<typeof NavigationMenuPrimitive.Item>) {
return (
<NavigationMenuPrimitive.Item
data-slot="navigation-menu-item"
className={cn('relative', className)}
{...props}
/>
)
}
const navigationMenuTriggerStyle = cva(
'group inline-flex h-9 w-max items-center justify-center rounded-md bg-background px-4 py-2 text-sm font-medium hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground disabled:pointer-events-none disabled:opacity-50 data-[state=open]:hover:bg-accent data-[state=open]:text-accent-foreground data-[state=open]:focus:bg-accent data-[state=open]:bg-accent/50 focus-visible:ring-ring/50 outline-none transition-[color,box-shadow] focus-visible:ring-[3px] focus-visible:outline-1',
)
function NavigationMenuTrigger({
className,
children,
...props
}: React.ComponentProps<typeof NavigationMenuPrimitive.Trigger>) {
return (
<NavigationMenuPrimitive.Trigger
data-slot="navigation-menu-trigger"
className={cn(navigationMenuTriggerStyle(), 'group', className)}
{...props}
>
{children}{' '}
<ChevronDownIcon
className="relative top-[1px] ml-1 size-3 transition duration-300 group-data-[state=open]:rotate-180"
aria-hidden="true"
/>
</NavigationMenuPrimitive.Trigger>
)
}
function NavigationMenuContent({
className,
...props
}: React.ComponentProps<typeof NavigationMenuPrimitive.Content>) {
return (
<NavigationMenuPrimitive.Content
data-slot="navigation-menu-content"
className={cn(
'data-[motion^=from-]:animate-in data-[motion^=to-]:animate-out data-[motion^=from-]:fade-in data-[motion^=to-]:fade-out data-[motion=from-end]:slide-in-from-right-52 data-[motion=from-start]:slide-in-from-left-52 data-[motion=to-end]:slide-out-to-right-52 data-[motion=to-start]:slide-out-to-left-52 top-0 left-0 w-full p-2 pr-2.5 md:absolute md:w-auto',
'group-data-[viewport=false]/navigation-menu:bg-popover group-data-[viewport=false]/navigation-menu:text-popover-foreground group-data-[viewport=false]/navigation-menu:data-[state=open]:animate-in group-data-[viewport=false]/navigation-menu:data-[state=closed]:animate-out group-data-[viewport=false]/navigation-menu:data-[state=closed]:zoom-out-95 group-data-[viewport=false]/navigation-menu:data-[state=open]:zoom-in-95 group-data-[viewport=false]/navigation-menu:data-[state=open]:fade-in-0 group-data-[viewport=false]/navigation-menu:data-[state=closed]:fade-out-0 group-data-[viewport=false]/navigation-menu:top-full group-data-[viewport=false]/navigation-menu:mt-1.5 group-data-[viewport=false]/navigation-menu:overflow-hidden group-data-[viewport=false]/navigation-menu:rounded-md group-data-[viewport=false]/navigation-menu:border group-data-[viewport=false]/navigation-menu:shadow group-data-[viewport=false]/navigation-menu:duration-200 **:data-[slot=navigation-menu-link]:focus:ring-0 **:data-[slot=navigation-menu-link]:focus:outline-none',
className,
)}
{...props}
/>
)
}
function NavigationMenuViewport({
className,
...props
}: React.ComponentProps<typeof NavigationMenuPrimitive.Viewport>) {
return (
<div
className="absolute top-full left-0 isolate z-50 flex justify-center"
>
<NavigationMenuPrimitive.Viewport
data-slot="navigation-menu-viewport"
className={cn(
'origin-top-center bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-90 relative mt-1.5 h-[var(--radix-navigation-menu-viewport-height)] w-full overflow-hidden rounded-md border shadow md:w-[var(--radix-navigation-menu-viewport-width)]',
className,
)}
{...props}
/>
</div>
)
}
function NavigationMenuLink({
className,
...props
}: React.ComponentProps<typeof NavigationMenuPrimitive.Link>) {
return (
<NavigationMenuPrimitive.Link
data-slot="navigation-menu-link"
className={cn(
"data-[active=true]:focus:bg-accent data-[active=true]:hover:bg-accent data-[active=true]:bg-accent/50 data-[active=true]:text-accent-foreground hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground focus-visible:ring-ring/50 [&_svg:not([class*='text-'])]:text-muted-foreground flex flex-col gap-1 rounded-sm p-2 text-sm transition-all outline-none focus-visible:ring-[3px] focus-visible:outline-1 [&_svg:not([class*='size-'])]:size-4",
className,
)}
{...props}
/>
)
}
function NavigationMenuIndicator({
className,
...props
}: React.ComponentProps<typeof NavigationMenuPrimitive.Indicator>) {
return (
<NavigationMenuPrimitive.Indicator
data-slot="navigation-menu-indicator"
className={cn(
'data-[state=visible]:animate-in data-[state=hidden]:animate-out data-[state=hidden]:fade-out data-[state=visible]:fade-in top-full z-[1] flex h-1.5 items-end justify-center overflow-hidden',
className,
)}
{...props}
>
<div className="bg-border relative top-[60%] h-2 w-2 rotate-45 rounded-tl-sm shadow-md" />
</NavigationMenuPrimitive.Indicator>
)
}
export {
NavigationMenu,
NavigationMenuList,
NavigationMenuItem,
NavigationMenuContent,
NavigationMenuTrigger,
NavigationMenuLink,
NavigationMenuIndicator,
NavigationMenuViewport,
navigationMenuTriggerStyle,
}
+127
View File
@@ -0,0 +1,127 @@
import * as React from 'react'
import {
ChevronLeftIcon,
ChevronRightIcon,
MoreHorizontalIcon,
} from 'lucide-react'
import { cn } from '@/lib/utils'
import { Button, buttonVariants } from '@/components/ui/button'
function Pagination({ className, ...props }: React.ComponentProps<'nav'>) {
return (
<nav
role="navigation"
aria-label="pagination"
data-slot="pagination"
className={cn('mx-auto flex w-full justify-center', className)}
{...props}
/>
)
}
function PaginationContent({
className,
...props
}: React.ComponentProps<'ul'>) {
return (
<ul
data-slot="pagination-content"
className={cn('flex flex-row items-center gap-1', className)}
{...props}
/>
)
}
function PaginationItem({ ...props }: React.ComponentProps<'li'>) {
return <li data-slot="pagination-item" {...props} />
}
type PaginationLinkProps = {
isActive?: boolean
} & Pick<React.ComponentProps<typeof Button>, 'size'> &
React.ComponentProps<'a'>
function PaginationLink({
className,
isActive,
size = 'icon',
...props
}: PaginationLinkProps) {
return (
<a
aria-current={isActive ? 'page' : undefined}
data-slot="pagination-link"
data-active={isActive}
className={cn(
buttonVariants({
variant: isActive ? 'outline' : 'ghost',
size,
}),
className,
)}
{...props}
/>
)
}
function PaginationPrevious({
className,
...props
}: React.ComponentProps<typeof PaginationLink>) {
return (
<PaginationLink
aria-label="Go to previous page"
size="default"
className={cn('gap-1 px-2.5 sm:pl-2.5', className)}
{...props}
>
<ChevronLeftIcon />
<span className="hidden sm:block">Previous</span>
</PaginationLink>
)
}
function PaginationNext({
className,
...props
}: React.ComponentProps<typeof PaginationLink>) {
return (
<PaginationLink
aria-label="Go to next page"
size="default"
className={cn('gap-1 px-2.5 sm:pr-2.5', className)}
{...props}
>
<span className="hidden sm:block">Next</span>
<ChevronRightIcon />
</PaginationLink>
)
}
function PaginationEllipsis({
className,
...props
}: React.ComponentProps<'span'>) {
return (
<span
aria-hidden
data-slot="pagination-ellipsis"
className={cn('flex size-9 items-center justify-center', className)}
{...props}
>
<MoreHorizontalIcon className="size-4" />
<span className="sr-only">More pages</span>
</span>
)
}
export {
Pagination,
PaginationContent,
PaginationLink,
PaginationItem,
PaginationPrevious,
PaginationNext,
PaginationEllipsis,
}
+48
View File
@@ -0,0 +1,48 @@
'use client'
import * as React from 'react'
import * as PopoverPrimitive from '@radix-ui/react-popover'
import { cn } from '@/lib/utils'
function Popover({
...props
}: React.ComponentProps<typeof PopoverPrimitive.Root>) {
return <PopoverPrimitive.Root data-slot="popover" {...props} />
}
function PopoverTrigger({
...props
}: React.ComponentProps<typeof PopoverPrimitive.Trigger>) {
return <PopoverPrimitive.Trigger data-slot="popover-trigger" {...props} />
}
function PopoverContent({
className,
align = 'center',
sideOffset = 4,
...props
}: React.ComponentProps<typeof PopoverPrimitive.Content>) {
return (
<PopoverPrimitive.Portal>
<PopoverPrimitive.Content
data-slot="popover-content"
align={align}
sideOffset={sideOffset}
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 w-72 origin-(--radix-popover-content-transform-origin) rounded-md border p-4 shadow-md outline-hidden',
className,
)}
{...props}
/>
</PopoverPrimitive.Portal>
)
}
function PopoverAnchor({
...props
}: React.ComponentProps<typeof PopoverPrimitive.Anchor>) {
return <PopoverPrimitive.Anchor data-slot="popover-anchor" {...props} />
}
export { Popover, PopoverTrigger, PopoverContent, PopoverAnchor }
+31
View File
@@ -0,0 +1,31 @@
'use client'
import * as React from 'react'
import * as ProgressPrimitive from '@radix-ui/react-progress'
import { cn } from '@/lib/utils'
function Progress({
className,
value,
...props
}: React.ComponentProps<typeof ProgressPrimitive.Root>) {
return (
<ProgressPrimitive.Root
data-slot="progress"
className={cn(
'bg-primary/20 relative h-2 w-full overflow-hidden rounded-full',
className,
)}
{...props}
>
<ProgressPrimitive.Indicator
data-slot="progress-indicator"
className="bg-primary h-full w-full flex-1 transition-all"
style={{ transform: `translateX(-${100 - (value || 0)}%)` }}
/>
</ProgressPrimitive.Root>
)
}
export { Progress }
+45
View File
@@ -0,0 +1,45 @@
'use client'
import * as React from 'react'
import * as RadioGroupPrimitive from '@radix-ui/react-radio-group'
import { CircleIcon } from 'lucide-react'
import { cn } from '@/lib/utils'
function RadioGroup({
className,
...props
}: React.ComponentProps<typeof RadioGroupPrimitive.Root>) {
return (
<RadioGroupPrimitive.Root
data-slot="radio-group"
className={cn('grid gap-3', className)}
{...props}
/>
)
}
function RadioGroupItem({
className,
...props
}: React.ComponentProps<typeof RadioGroupPrimitive.Item>) {
return (
<RadioGroupPrimitive.Item
data-slot="radio-group-item"
className={cn(
'border-input text-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 dark:bg-input/30 aspect-square size-4 shrink-0 rounded-full border shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50',
className,
)}
{...props}
>
<RadioGroupPrimitive.Indicator
data-slot="radio-group-indicator"
className="relative flex items-center justify-center"
>
<CircleIcon className="fill-primary absolute top-1/2 left-1/2 size-2 -translate-x-1/2 -translate-y-1/2" />
</RadioGroupPrimitive.Indicator>
</RadioGroupPrimitive.Item>
)
}
export { RadioGroup, RadioGroupItem }
+56
View File
@@ -0,0 +1,56 @@
'use client'
import * as React from 'react'
import { GripVerticalIcon } from 'lucide-react'
import * as ResizablePrimitive from 'react-resizable-panels'
import { cn } from '@/lib/utils'
function ResizablePanelGroup({
className,
...props
}: React.ComponentProps<typeof ResizablePrimitive.PanelGroup>) {
return (
<ResizablePrimitive.PanelGroup
data-slot="resizable-panel-group"
className={cn(
'flex h-full w-full data-[panel-group-direction=vertical]:flex-col',
className,
)}
{...props}
/>
)
}
function ResizablePanel({
...props
}: React.ComponentProps<typeof ResizablePrimitive.Panel>) {
return <ResizablePrimitive.Panel data-slot="resizable-panel" {...props} />
}
function ResizableHandle({
withHandle,
className,
...props
}: React.ComponentProps<typeof ResizablePrimitive.PanelResizeHandle> & {
withHandle?: boolean
}) {
return (
<ResizablePrimitive.PanelResizeHandle
data-slot="resizable-handle"
className={cn(
'bg-border focus-visible:ring-ring relative flex w-px items-center justify-center after:absolute after:inset-y-0 after:left-1/2 after:w-1 after:-translate-x-1/2 focus-visible:ring-1 focus-visible:ring-offset-1 focus-visible:outline-hidden data-[panel-group-direction=vertical]:h-px data-[panel-group-direction=vertical]:w-full data-[panel-group-direction=vertical]:after:left-0 data-[panel-group-direction=vertical]:after:h-1 data-[panel-group-direction=vertical]:after:w-full data-[panel-group-direction=vertical]:after:translate-x-0 data-[panel-group-direction=vertical]:after:-translate-y-1/2 [&[data-panel-group-direction=vertical]>div]:rotate-90',
className,
)}
{...props}
>
{withHandle && (
<div className="bg-border z-10 flex h-4 w-3 items-center justify-center rounded-xs border">
<GripVerticalIcon className="size-2.5" />
</div>
)}
</ResizablePrimitive.PanelResizeHandle>
)
}
export { ResizablePanelGroup, ResizablePanel, ResizableHandle }
+58
View File
@@ -0,0 +1,58 @@
'use client'
import * as React from 'react'
import * as ScrollAreaPrimitive from '@radix-ui/react-scroll-area'
import { cn } from '@/lib/utils'
function ScrollArea({
className,
children,
...props
}: React.ComponentProps<typeof ScrollAreaPrimitive.Root>) {
return (
<ScrollAreaPrimitive.Root
data-slot="scroll-area"
className={cn('relative', className)}
{...props}
>
<ScrollAreaPrimitive.Viewport
data-slot="scroll-area-viewport"
className="focus-visible:ring-ring/50 size-full rounded-[inherit] transition-[color,box-shadow] outline-none focus-visible:ring-[3px] focus-visible:outline-1"
>
{children}
</ScrollAreaPrimitive.Viewport>
<ScrollBar />
<ScrollAreaPrimitive.Corner />
</ScrollAreaPrimitive.Root>
)
}
function ScrollBar({
className,
orientation = 'vertical',
...props
}: React.ComponentProps<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>) {
return (
<ScrollAreaPrimitive.ScrollAreaScrollbar
data-slot="scroll-area-scrollbar"
orientation={orientation}
className={cn(
'flex touch-none p-px transition-colors select-none',
orientation === 'vertical' &&
'h-full w-2.5 border-l border-l-transparent',
orientation === 'horizontal' &&
'h-2.5 flex-col border-t border-t-transparent',
className,
)}
{...props}
>
<ScrollAreaPrimitive.ScrollAreaThumb
data-slot="scroll-area-thumb"
className="bg-border relative flex-1 rounded-full"
/>
</ScrollAreaPrimitive.ScrollAreaScrollbar>
)
}
export { ScrollArea, ScrollBar }
+185
View File
@@ -0,0 +1,185 @@
'use client'
import * as React from 'react'
import * as SelectPrimitive from '@radix-ui/react-select'
import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from 'lucide-react'
import { cn } from '@/lib/utils'
function Select({
...props
}: React.ComponentProps<typeof SelectPrimitive.Root>) {
return <SelectPrimitive.Root data-slot="select" {...props} />
}
function SelectGroup({
...props
}: React.ComponentProps<typeof SelectPrimitive.Group>) {
return <SelectPrimitive.Group data-slot="select-group" {...props} />
}
function SelectValue({
...props
}: React.ComponentProps<typeof SelectPrimitive.Value>) {
return <SelectPrimitive.Value data-slot="select-value" {...props} />
}
function SelectTrigger({
className,
size = 'default',
children,
...props
}: React.ComponentProps<typeof SelectPrimitive.Trigger> & {
size?: 'sm' | 'default'
}) {
return (
<SelectPrimitive.Trigger
data-slot="select-trigger"
data-size={size}
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",
className,
)}
{...props}
>
{children}
<SelectPrimitive.Icon asChild>
<ChevronDownIcon className="size-4 opacity-50" />
</SelectPrimitive.Icon>
</SelectPrimitive.Trigger>
)
}
function SelectContent({
className,
children,
position = 'popper',
...props
}: React.ComponentProps<typeof SelectPrimitive.Content>) {
return (
<SelectPrimitive.Portal>
<SelectPrimitive.Content
data-slot="select-content"
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',
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',
className,
)}
position={position}
{...props}
>
<SelectScrollUpButton />
<SelectPrimitive.Viewport
className={cn(
'p-1',
position === 'popper' &&
'h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)] scroll-my-1',
)}
>
{children}
</SelectPrimitive.Viewport>
<SelectScrollDownButton />
</SelectPrimitive.Content>
</SelectPrimitive.Portal>
)
}
function SelectLabel({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.Label>) {
return (
<SelectPrimitive.Label
data-slot="select-label"
className={cn('text-muted-foreground px-2 py-1.5 text-xs', className)}
{...props}
/>
)
}
function SelectItem({
className,
children,
...props
}: React.ComponentProps<typeof SelectPrimitive.Item>) {
return (
<SelectPrimitive.Item
data-slot="select-item"
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",
className,
)}
{...props}
>
<span className="absolute right-2 flex size-3.5 items-center justify-center">
<SelectPrimitive.ItemIndicator>
<CheckIcon className="size-4" />
</SelectPrimitive.ItemIndicator>
</span>
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
</SelectPrimitive.Item>
)
}
function SelectSeparator({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.Separator>) {
return (
<SelectPrimitive.Separator
data-slot="select-separator"
className={cn('bg-border pointer-events-none -mx-1 my-1 h-px', className)}
{...props}
/>
)
}
function SelectScrollUpButton({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.ScrollUpButton>) {
return (
<SelectPrimitive.ScrollUpButton
data-slot="select-scroll-up-button"
className={cn(
'flex cursor-default items-center justify-center py-1',
className,
)}
{...props}
>
<ChevronUpIcon className="size-4" />
</SelectPrimitive.ScrollUpButton>
)
}
function SelectScrollDownButton({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.ScrollDownButton>) {
return (
<SelectPrimitive.ScrollDownButton
data-slot="select-scroll-down-button"
className={cn(
'flex cursor-default items-center justify-center py-1',
className,
)}
{...props}
>
<ChevronDownIcon className="size-4" />
</SelectPrimitive.ScrollDownButton>
)
}
export {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectLabel,
SelectScrollDownButton,
SelectScrollUpButton,
SelectSeparator,
SelectTrigger,
SelectValue,
}
+28
View File
@@ -0,0 +1,28 @@
'use client'
import * as React from 'react'
import * as SeparatorPrimitive from '@radix-ui/react-separator'
import { cn } from '@/lib/utils'
function Separator({
className,
orientation = 'horizontal',
decorative = true,
...props
}: React.ComponentProps<typeof SeparatorPrimitive.Root>) {
return (
<SeparatorPrimitive.Root
data-slot="separator"
decorative={decorative}
orientation={orientation}
className={cn(
'bg-border shrink-0 data-[orientation=horizontal]:h-px data-[orientation=horizontal]:w-full data-[orientation=vertical]:h-full data-[orientation=vertical]:w-px',
className,
)}
{...props}
/>
)
}
export { Separator }
+139
View File
@@ -0,0 +1,139 @@
'use client'
import * as React from 'react'
import * as SheetPrimitive from '@radix-ui/react-dialog'
import { XIcon } from 'lucide-react'
import { cn } from '@/lib/utils'
function Sheet({ ...props }: React.ComponentProps<typeof SheetPrimitive.Root>) {
return <SheetPrimitive.Root data-slot="sheet" {...props} />
}
function SheetTrigger({
...props
}: React.ComponentProps<typeof SheetPrimitive.Trigger>) {
return <SheetPrimitive.Trigger data-slot="sheet-trigger" {...props} />
}
function SheetClose({
...props
}: React.ComponentProps<typeof SheetPrimitive.Close>) {
return <SheetPrimitive.Close data-slot="sheet-close" {...props} />
}
function SheetPortal({
...props
}: React.ComponentProps<typeof SheetPrimitive.Portal>) {
return <SheetPrimitive.Portal data-slot="sheet-portal" {...props} />
}
function SheetOverlay({
className,
...props
}: React.ComponentProps<typeof SheetPrimitive.Overlay>) {
return (
<SheetPrimitive.Overlay
data-slot="sheet-overlay"
className={cn(
'data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50',
className,
)}
{...props}
/>
)
}
function SheetContent({
className,
children,
side = 'right',
...props
}: React.ComponentProps<typeof SheetPrimitive.Content> & {
side?: 'top' | 'right' | 'bottom' | 'left'
}) {
return (
<SheetPortal>
<SheetOverlay />
<SheetPrimitive.Content
data-slot="sheet-content"
className={cn(
'bg-background data-[state=open]:animate-in data-[state=closed]:animate-out fixed z-50 flex flex-col gap-4 shadow-lg transition ease-in-out data-[state=closed]:duration-300 data-[state=open]:duration-500',
side === 'right' &&
'data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right inset-y-0 right-0 h-full w-3/4 border-l sm:max-w-sm',
side === 'left' &&
'data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left inset-y-0 left-0 h-full w-3/4 border-r sm:max-w-sm',
side === 'top' &&
'data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top inset-x-0 top-0 h-auto border-b',
side === 'bottom' &&
'data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom inset-x-0 bottom-0 h-auto border-t',
className,
)}
{...props}
>
{children}
<SheetPrimitive.Close className="ring-offset-background focus:ring-ring data-[state=open]:bg-secondary 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">
<XIcon className="size-4" />
<span className="sr-only">Close</span>
</SheetPrimitive.Close>
</SheetPrimitive.Content>
</SheetPortal>
)
}
function SheetHeader({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot="sheet-header"
className={cn('flex flex-col gap-1.5 p-4', className)}
{...props}
/>
)
}
function SheetFooter({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot="sheet-footer"
className={cn('mt-auto flex flex-col gap-2 p-4', className)}
{...props}
/>
)
}
function SheetTitle({
className,
...props
}: React.ComponentProps<typeof SheetPrimitive.Title>) {
return (
<SheetPrimitive.Title
data-slot="sheet-title"
className={cn('text-foreground font-semibold', className)}
{...props}
/>
)
}
function SheetDescription({
className,
...props
}: React.ComponentProps<typeof SheetPrimitive.Description>) {
return (
<SheetPrimitive.Description
data-slot="sheet-description"
className={cn('text-muted-foreground text-sm', className)}
{...props}
/>
)
}
export {
Sheet,
SheetTrigger,
SheetClose,
SheetContent,
SheetHeader,
SheetFooter,
SheetTitle,
SheetDescription,
}
+726
View File
@@ -0,0 +1,726 @@
'use client'
import * as React from 'react'
import { Slot } from '@radix-ui/react-slot'
import { cva, VariantProps } from 'class-variance-authority'
import { PanelLeftIcon } from 'lucide-react'
import { useIsMobile } from '@/hooks/use-mobile'
import { cn } from '@/lib/utils'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Separator } from '@/components/ui/separator'
import {
Sheet,
SheetContent,
SheetDescription,
SheetHeader,
SheetTitle,
} from '@/components/ui/sheet'
import { Skeleton } from '@/components/ui/skeleton'
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from '@/components/ui/tooltip'
const SIDEBAR_COOKIE_NAME = 'sidebar_state'
const SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 7
const SIDEBAR_WIDTH = '16rem'
const SIDEBAR_WIDTH_MOBILE = '18rem'
const SIDEBAR_WIDTH_ICON = '3rem'
const SIDEBAR_KEYBOARD_SHORTCUT = 'b'
type SidebarContextProps = {
state: 'expanded' | 'collapsed'
open: boolean
setOpen: (open: boolean) => void
openMobile: boolean
setOpenMobile: (open: boolean) => void
isMobile: boolean
toggleSidebar: () => void
}
const SidebarContext = React.createContext<SidebarContextProps | null>(null)
function useSidebar() {
const context = React.useContext(SidebarContext)
if (!context) {
throw new Error('useSidebar must be used within a SidebarProvider.')
}
return context
}
function SidebarProvider({
defaultOpen = true,
open: openProp,
onOpenChange: setOpenProp,
className,
style,
children,
...props
}: React.ComponentProps<'div'> & {
defaultOpen?: boolean
open?: boolean
onOpenChange?: (open: boolean) => void
}) {
const isMobile = useIsMobile()
const [openMobile, setOpenMobile] = React.useState(false)
// This is the internal state of the sidebar.
// We use openProp and setOpenProp for control from outside the component.
const [_open, _setOpen] = React.useState(defaultOpen)
const open = openProp ?? _open
const setOpen = React.useCallback(
(value: boolean | ((value: boolean) => boolean)) => {
const openState = typeof value === 'function' ? value(open) : value
if (setOpenProp) {
setOpenProp(openState)
} else {
_setOpen(openState)
}
// This sets the cookie to keep the sidebar state.
document.cookie = `${SIDEBAR_COOKIE_NAME}=${openState}; path=/; max-age=${SIDEBAR_COOKIE_MAX_AGE}`
},
[setOpenProp, open],
)
// Helper to toggle the sidebar.
const toggleSidebar = React.useCallback(() => {
return isMobile ? setOpenMobile((open) => !open) : setOpen((open) => !open)
}, [isMobile, setOpen, setOpenMobile])
// Adds a keyboard shortcut to toggle the sidebar.
React.useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
if (
event.key === SIDEBAR_KEYBOARD_SHORTCUT &&
(event.metaKey || event.ctrlKey)
) {
event.preventDefault()
toggleSidebar()
}
}
window.addEventListener('keydown', handleKeyDown)
return () => window.removeEventListener('keydown', handleKeyDown)
}, [toggleSidebar])
// We add a state so that we can do data-state="expanded" or "collapsed".
// This makes it easier to style the sidebar with Tailwind classes.
const state = open ? 'expanded' : 'collapsed'
const contextValue = React.useMemo<SidebarContextProps>(
() => ({
state,
open,
setOpen,
isMobile,
openMobile,
setOpenMobile,
toggleSidebar,
}),
[state, open, setOpen, isMobile, openMobile, setOpenMobile, toggleSidebar],
)
return (
<SidebarContext.Provider value={contextValue}>
<TooltipProvider delayDuration={0}>
<div
data-slot="sidebar-wrapper"
style={
{
'--sidebar-width': SIDEBAR_WIDTH,
'--sidebar-width-icon': SIDEBAR_WIDTH_ICON,
...style,
} as React.CSSProperties
}
className={cn(
'group/sidebar-wrapper has-data-[variant=inset]:bg-sidebar flex min-h-svh w-full',
className,
)}
{...props}
>
{children}
</div>
</TooltipProvider>
</SidebarContext.Provider>
)
}
function Sidebar({
side = 'left',
variant = 'sidebar',
collapsible = 'offcanvas',
className,
children,
...props
}: React.ComponentProps<'div'> & {
side?: 'left' | 'right'
variant?: 'sidebar' | 'floating' | 'inset'
collapsible?: 'offcanvas' | 'icon' | 'none'
}) {
const { isMobile, state, openMobile, setOpenMobile } = useSidebar()
if (collapsible === 'none') {
return (
<div
data-slot="sidebar"
className={cn(
'bg-sidebar text-sidebar-foreground flex h-full w-(--sidebar-width) flex-col',
className,
)}
{...props}
>
{children}
</div>
)
}
if (isMobile) {
return (
<Sheet open={openMobile} onOpenChange={setOpenMobile} {...props}>
<SheetContent
data-sidebar="sidebar"
data-slot="sidebar"
data-mobile="true"
className="bg-sidebar text-sidebar-foreground w-(--sidebar-width) p-0 [&>button]:hidden"
style={
{
'--sidebar-width': SIDEBAR_WIDTH_MOBILE,
} as React.CSSProperties
}
side={side}
>
<SheetHeader className="sr-only">
<SheetTitle>Sidebar</SheetTitle>
<SheetDescription>Displays the mobile sidebar.</SheetDescription>
</SheetHeader>
<div className="flex h-full w-full flex-col">{children}</div>
</SheetContent>
</Sheet>
)
}
return (
<div
className="group peer text-sidebar-foreground hidden md:block"
data-state={state}
data-collapsible={state === 'collapsed' ? collapsible : ''}
data-variant={variant}
data-side={side}
data-slot="sidebar"
>
{/* This is what handles the sidebar gap on desktop */}
<div
data-slot="sidebar-gap"
className={cn(
'relative w-(--sidebar-width) bg-transparent transition-[width] duration-200 ease-linear',
'group-data-[collapsible=offcanvas]:w-0',
'group-data-[side=right]:rotate-180',
variant === 'floating' || variant === 'inset'
? 'group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+(--spacing(4)))]'
: 'group-data-[collapsible=icon]:w-(--sidebar-width-icon)',
)}
/>
<div
data-slot="sidebar-container"
className={cn(
'fixed inset-y-0 z-10 hidden h-svh w-(--sidebar-width) transition-[left,right,width] duration-200 ease-linear md:flex',
side === 'left'
? 'left-0 group-data-[collapsible=offcanvas]:left-[calc(var(--sidebar-width)*-1)]'
: 'right-0 group-data-[collapsible=offcanvas]:right-[calc(var(--sidebar-width)*-1)]',
// Adjust the padding for floating and inset variants.
variant === 'floating' || variant === 'inset'
? 'p-2 group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+(--spacing(4))+2px)]'
: 'group-data-[collapsible=icon]:w-(--sidebar-width-icon) group-data-[side=left]:border-r group-data-[side=right]:border-l',
className,
)}
{...props}
>
<div
data-sidebar="sidebar"
data-slot="sidebar-inner"
className="bg-sidebar group-data-[variant=floating]:border-sidebar-border flex h-full w-full flex-col group-data-[variant=floating]:rounded-lg group-data-[variant=floating]:border group-data-[variant=floating]:shadow-sm"
>
{children}
</div>
</div>
</div>
)
}
function SidebarTrigger({
className,
onClick,
...props
}: React.ComponentProps<typeof Button>) {
const { toggleSidebar } = useSidebar()
return (
<Button
data-sidebar="trigger"
data-slot="sidebar-trigger"
variant="ghost"
size="icon"
className={cn('size-7', className)}
onClick={(event) => {
onClick?.(event)
toggleSidebar()
}}
{...props}
>
<PanelLeftIcon />
<span className="sr-only">Toggle Sidebar</span>
</Button>
)
}
function SidebarRail({ className, ...props }: React.ComponentProps<'button'>) {
const { toggleSidebar } = useSidebar()
return (
<button
data-sidebar="rail"
data-slot="sidebar-rail"
aria-label="Toggle Sidebar"
tabIndex={-1}
onClick={toggleSidebar}
title="Toggle Sidebar"
className={cn(
'hover:after:bg-sidebar-border absolute inset-y-0 z-20 hidden w-4 -translate-x-1/2 transition-all ease-linear group-data-[side=left]:-right-4 group-data-[side=right]:left-0 after:absolute after:inset-y-0 after:left-1/2 after:w-[2px] sm:flex',
'in-data-[side=left]:cursor-w-resize in-data-[side=right]:cursor-e-resize',
'[[data-side=left][data-state=collapsed]_&]:cursor-e-resize [[data-side=right][data-state=collapsed]_&]:cursor-w-resize',
'hover:group-data-[collapsible=offcanvas]:bg-sidebar group-data-[collapsible=offcanvas]:translate-x-0 group-data-[collapsible=offcanvas]:after:left-full',
'[[data-side=left][data-collapsible=offcanvas]_&]:-right-2',
'[[data-side=right][data-collapsible=offcanvas]_&]:-left-2',
className,
)}
{...props}
/>
)
}
function SidebarInset({ className, ...props }: React.ComponentProps<'main'>) {
return (
<main
data-slot="sidebar-inset"
className={cn(
'bg-background relative flex w-full flex-1 flex-col',
'md:peer-data-[variant=inset]:m-2 md:peer-data-[variant=inset]:ml-0 md:peer-data-[variant=inset]:rounded-xl md:peer-data-[variant=inset]:shadow-sm md:peer-data-[variant=inset]:peer-data-[state=collapsed]:ml-2',
className,
)}
{...props}
/>
)
}
function SidebarInput({
className,
...props
}: React.ComponentProps<typeof Input>) {
return (
<Input
data-slot="sidebar-input"
data-sidebar="input"
className={cn('bg-background h-8 w-full shadow-none', className)}
{...props}
/>
)
}
function SidebarHeader({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot="sidebar-header"
data-sidebar="header"
className={cn('flex flex-col gap-2 p-2', className)}
{...props}
/>
)
}
function SidebarFooter({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot="sidebar-footer"
data-sidebar="footer"
className={cn('flex flex-col gap-2 p-2', className)}
{...props}
/>
)
}
function SidebarSeparator({
className,
...props
}: React.ComponentProps<typeof Separator>) {
return (
<Separator
data-slot="sidebar-separator"
data-sidebar="separator"
className={cn('bg-sidebar-border mx-2 w-auto', className)}
{...props}
/>
)
}
function SidebarContent({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot="sidebar-content"
data-sidebar="content"
className={cn(
'flex min-h-0 flex-1 flex-col gap-2 overflow-auto group-data-[collapsible=icon]:overflow-hidden',
className,
)}
{...props}
/>
)
}
function SidebarGroup({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot="sidebar-group"
data-sidebar="group"
className={cn('relative flex w-full min-w-0 flex-col p-2', className)}
{...props}
/>
)
}
function SidebarGroupLabel({
className,
asChild = false,
...props
}: React.ComponentProps<'div'> & { asChild?: boolean }) {
const Comp = asChild ? Slot : 'div'
return (
<Comp
data-slot="sidebar-group-label"
data-sidebar="group-label"
className={cn(
'text-sidebar-foreground/70 ring-sidebar-ring flex h-8 shrink-0 items-center rounded-md px-2 text-xs font-medium outline-hidden transition-[margin,opacity] duration-200 ease-linear focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0',
'group-data-[collapsible=icon]:-mt-8 group-data-[collapsible=icon]:opacity-0',
className,
)}
{...props}
/>
)
}
function SidebarGroupAction({
className,
asChild = false,
...props
}: React.ComponentProps<'button'> & { asChild?: boolean }) {
const Comp = asChild ? Slot : 'button'
return (
<Comp
data-slot="sidebar-group-action"
data-sidebar="group-action"
className={cn(
'text-sidebar-foreground ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground absolute top-3.5 right-3 flex aspect-square w-5 items-center justify-center rounded-md p-0 outline-hidden transition-transform focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0',
// Increases the hit area of the button on mobile.
'after:absolute after:-inset-2 md:after:hidden',
'group-data-[collapsible=icon]:hidden',
className,
)}
{...props}
/>
)
}
function SidebarGroupContent({
className,
...props
}: React.ComponentProps<'div'>) {
return (
<div
data-slot="sidebar-group-content"
data-sidebar="group-content"
className={cn('w-full text-sm', className)}
{...props}
/>
)
}
function SidebarMenu({ className, ...props }: React.ComponentProps<'ul'>) {
return (
<ul
data-slot="sidebar-menu"
data-sidebar="menu"
className={cn('flex w-full min-w-0 flex-col gap-1', className)}
{...props}
/>
)
}
function SidebarMenuItem({ className, ...props }: React.ComponentProps<'li'>) {
return (
<li
data-slot="sidebar-menu-item"
data-sidebar="menu-item"
className={cn('group/menu-item relative', className)}
{...props}
/>
)
}
const sidebarMenuButtonVariants = cva(
'peer/menu-button flex w-full items-center gap-2 overflow-hidden rounded-md p-2 text-left text-sm outline-hidden ring-sidebar-ring transition-[width,height,padding] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 group-has-data-[sidebar=menu-action]/menu-item:pr-8 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-[active=true]:bg-sidebar-accent data-[active=true]:font-medium data-[active=true]:text-sidebar-accent-foreground data-[state=open]:hover:bg-sidebar-accent data-[state=open]:hover:text-sidebar-accent-foreground group-data-[collapsible=icon]:size-8! group-data-[collapsible=icon]:p-2! [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0',
{
variants: {
variant: {
default: 'hover:bg-sidebar-accent hover:text-sidebar-accent-foreground',
outline:
'bg-background shadow-[0_0_0_1px_hsl(var(--sidebar-border))] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground hover:shadow-[0_0_0_1px_hsl(var(--sidebar-accent))]',
},
size: {
default: 'h-8 text-sm',
sm: 'h-7 text-xs',
lg: 'h-12 text-sm group-data-[collapsible=icon]:p-0!',
},
},
defaultVariants: {
variant: 'default',
size: 'default',
},
},
)
function SidebarMenuButton({
asChild = false,
isActive = false,
variant = 'default',
size = 'default',
tooltip,
className,
...props
}: React.ComponentProps<'button'> & {
asChild?: boolean
isActive?: boolean
tooltip?: string | React.ComponentProps<typeof TooltipContent>
} & VariantProps<typeof sidebarMenuButtonVariants>) {
const Comp = asChild ? Slot : 'button'
const { isMobile, state } = useSidebar()
const button = (
<Comp
data-slot="sidebar-menu-button"
data-sidebar="menu-button"
data-size={size}
data-active={isActive}
className={cn(sidebarMenuButtonVariants({ variant, size }), className)}
{...props}
/>
)
if (!tooltip) {
return button
}
if (typeof tooltip === 'string') {
tooltip = {
children: tooltip,
}
}
return (
<Tooltip>
<TooltipTrigger asChild>{button}</TooltipTrigger>
<TooltipContent
side="right"
align="center"
hidden={state !== 'collapsed' || isMobile}
{...tooltip}
/>
</Tooltip>
)
}
function SidebarMenuAction({
className,
asChild = false,
showOnHover = false,
...props
}: React.ComponentProps<'button'> & {
asChild?: boolean
showOnHover?: boolean
}) {
const Comp = asChild ? Slot : 'button'
return (
<Comp
data-slot="sidebar-menu-action"
data-sidebar="menu-action"
className={cn(
'text-sidebar-foreground ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground peer-hover/menu-button:text-sidebar-accent-foreground absolute top-1.5 right-1 flex aspect-square w-5 items-center justify-center rounded-md p-0 outline-hidden transition-transform focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0',
// Increases the hit area of the button on mobile.
'after:absolute after:-inset-2 md:after:hidden',
'peer-data-[size=sm]/menu-button:top-1',
'peer-data-[size=default]/menu-button:top-1.5',
'peer-data-[size=lg]/menu-button:top-2.5',
'group-data-[collapsible=icon]:hidden',
showOnHover &&
'peer-data-[active=true]/menu-button:text-sidebar-accent-foreground group-focus-within/menu-item:opacity-100 group-hover/menu-item:opacity-100 data-[state=open]:opacity-100 md:opacity-0',
className,
)}
{...props}
/>
)
}
function SidebarMenuBadge({
className,
...props
}: React.ComponentProps<'div'>) {
return (
<div
data-slot="sidebar-menu-badge"
data-sidebar="menu-badge"
className={cn(
'text-sidebar-foreground pointer-events-none absolute right-1 flex h-5 min-w-5 items-center justify-center rounded-md px-1 text-xs font-medium tabular-nums select-none',
'peer-hover/menu-button:text-sidebar-accent-foreground peer-data-[active=true]/menu-button:text-sidebar-accent-foreground',
'peer-data-[size=sm]/menu-button:top-1',
'peer-data-[size=default]/menu-button:top-1.5',
'peer-data-[size=lg]/menu-button:top-2.5',
'group-data-[collapsible=icon]:hidden',
className,
)}
{...props}
/>
)
}
function SidebarMenuSkeleton({
className,
showIcon = false,
...props
}: React.ComponentProps<'div'> & {
showIcon?: boolean
}) {
// Random width between 50 to 90%.
const width = React.useMemo(() => {
return `${Math.floor(Math.random() * 40) + 50}%`
}, [])
return (
<div
data-slot="sidebar-menu-skeleton"
data-sidebar="menu-skeleton"
className={cn('flex h-8 items-center gap-2 rounded-md px-2', className)}
{...props}
>
{showIcon && (
<Skeleton
className="size-4 rounded-md"
data-sidebar="menu-skeleton-icon"
/>
)}
<Skeleton
className="h-4 max-w-(--skeleton-width) flex-1"
data-sidebar="menu-skeleton-text"
style={
{
'--skeleton-width': width,
} as React.CSSProperties
}
/>
</div>
)
}
function SidebarMenuSub({ className, ...props }: React.ComponentProps<'ul'>) {
return (
<ul
data-slot="sidebar-menu-sub"
data-sidebar="menu-sub"
className={cn(
'border-sidebar-border mx-3.5 flex min-w-0 translate-x-px flex-col gap-1 border-l px-2.5 py-0.5',
'group-data-[collapsible=icon]:hidden',
className,
)}
{...props}
/>
)
}
function SidebarMenuSubItem({
className,
...props
}: React.ComponentProps<'li'>) {
return (
<li
data-slot="sidebar-menu-sub-item"
data-sidebar="menu-sub-item"
className={cn('group/menu-sub-item relative', className)}
{...props}
/>
)
}
function SidebarMenuSubButton({
asChild = false,
size = 'md',
isActive = false,
className,
...props
}: React.ComponentProps<'a'> & {
asChild?: boolean
size?: 'sm' | 'md'
isActive?: boolean
}) {
const Comp = asChild ? Slot : 'a'
return (
<Comp
data-slot="sidebar-menu-sub-button"
data-sidebar="menu-sub-button"
data-size={size}
data-active={isActive}
className={cn(
'text-sidebar-foreground ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground active:bg-sidebar-accent active:text-sidebar-accent-foreground [&>svg]:text-sidebar-accent-foreground flex h-7 min-w-0 -translate-x-px items-center gap-2 overflow-hidden rounded-md px-2 outline-hidden focus-visible:ring-2 disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0',
'data-[active=true]:bg-sidebar-accent data-[active=true]:text-sidebar-accent-foreground',
size === 'sm' && 'text-xs',
size === 'md' && 'text-sm',
'group-data-[collapsible=icon]:hidden',
className,
)}
{...props}
/>
)
}
export {
Sidebar,
SidebarContent,
SidebarFooter,
SidebarGroup,
SidebarGroupAction,
SidebarGroupContent,
SidebarGroupLabel,
SidebarHeader,
SidebarInput,
SidebarInset,
SidebarMenu,
SidebarMenuAction,
SidebarMenuBadge,
SidebarMenuButton,
SidebarMenuItem,
SidebarMenuSkeleton,
SidebarMenuSub,
SidebarMenuSubButton,
SidebarMenuSubItem,
SidebarProvider,
SidebarRail,
SidebarSeparator,
SidebarTrigger,
useSidebar,
}
+13
View File
@@ -0,0 +1,13 @@
import { cn } from '@/lib/utils'
function Skeleton({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot="skeleton"
className={cn('bg-accent animate-pulse rounded-md', className)}
{...props}
/>
)
}
export { Skeleton }
+63
View File
@@ -0,0 +1,63 @@
'use client'
import * as React from 'react'
import * as SliderPrimitive from '@radix-ui/react-slider'
import { cn } from '@/lib/utils'
function Slider({
className,
defaultValue,
value,
min = 0,
max = 100,
...props
}: React.ComponentProps<typeof SliderPrimitive.Root>) {
const _values = React.useMemo(
() =>
Array.isArray(value)
? value
: Array.isArray(defaultValue)
? defaultValue
: [min, max],
[value, defaultValue, min, max],
)
return (
<SliderPrimitive.Root
data-slot="slider"
defaultValue={defaultValue}
value={value}
min={min}
max={max}
className={cn(
'relative flex w-full touch-none items-center select-none data-[disabled]:opacity-50 data-[orientation=vertical]:h-full data-[orientation=vertical]:min-h-44 data-[orientation=vertical]:w-auto data-[orientation=vertical]:flex-col',
className,
)}
{...props}
>
<SliderPrimitive.Track
data-slot="slider-track"
className={
'bg-muted relative grow overflow-hidden rounded-full data-[orientation=horizontal]:h-1.5 data-[orientation=horizontal]:w-full data-[orientation=vertical]:h-full data-[orientation=vertical]:w-1.5'
}
>
<SliderPrimitive.Range
data-slot="slider-range"
className={
'bg-primary absolute data-[orientation=horizontal]:h-full data-[orientation=vertical]:w-full'
}
/>
</SliderPrimitive.Track>
{Array.from({ length: _values.length }, (_, index) => (
<SliderPrimitive.Thumb
data-slot="slider-thumb"
key={index}
className="border-primary ring-ring/50 block size-4 shrink-0 rounded-full border bg-white shadow-sm transition-[color,box-shadow] hover:ring-4 focus-visible:ring-4 focus-visible:outline-hidden disabled:pointer-events-none disabled:opacity-50"
/>
))}
</SliderPrimitive.Root>
)
}
export { Slider }
+25
View File
@@ -0,0 +1,25 @@
'use client'
import { useTheme } from 'next-themes'
import { Toaster as Sonner, ToasterProps } from 'sonner'
const Toaster = ({ ...props }: ToasterProps) => {
const { theme = 'system' } = useTheme()
return (
<Sonner
theme={theme as ToasterProps['theme']}
className="toaster group"
style={
{
'--normal-bg': 'var(--popover)',
'--normal-text': 'var(--popover-foreground)',
'--normal-border': 'var(--border)',
} as React.CSSProperties
}
{...props}
/>
)
}
export { Toaster }
+16
View File
@@ -0,0 +1,16 @@
import { Loader2Icon } from 'lucide-react'
import { cn } from '@/lib/utils'
function Spinner({ className, ...props }: React.ComponentProps<'svg'>) {
return (
<Loader2Icon
role="status"
aria-label="Loading"
className={cn('size-4 animate-spin', className)}
{...props}
/>
)
}
export { Spinner }
+31
View File
@@ -0,0 +1,31 @@
'use client'
import * as React from 'react'
import * as SwitchPrimitive from '@radix-ui/react-switch'
import { cn } from '@/lib/utils'
function Switch({
className,
...props
}: React.ComponentProps<typeof SwitchPrimitive.Root>) {
return (
<SwitchPrimitive.Root
data-slot="switch"
className={cn(
'peer data-[state=checked]:bg-primary data-[state=unchecked]:bg-input focus-visible:border-ring focus-visible:ring-ring/50 dark:data-[state=unchecked]:bg-input/80 inline-flex h-[1.15rem] w-8 shrink-0 items-center rounded-full border border-transparent shadow-xs transition-all outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50',
className,
)}
{...props}
>
<SwitchPrimitive.Thumb
data-slot="switch-thumb"
className={
'bg-background dark:data-[state=unchecked]:bg-foreground dark:data-[state=checked]:bg-primary-foreground pointer-events-none block size-4 rounded-full ring-0 transition-transform data-[state=checked]:translate-x-[calc(100%-2px)] data-[state=unchecked]:translate-x-0'
}
/>
</SwitchPrimitive.Root>
)
}
export { Switch }
+116
View File
@@ -0,0 +1,116 @@
'use client'
import * as React from 'react'
import { cn } from '@/lib/utils'
function Table({ className, ...props }: React.ComponentProps<'table'>) {
return (
<div
data-slot="table-container"
className="relative w-full overflow-x-auto"
>
<table
data-slot="table"
className={cn('w-full caption-bottom text-sm', className)}
{...props}
/>
</div>
)
}
function TableHeader({ className, ...props }: React.ComponentProps<'thead'>) {
return (
<thead
data-slot="table-header"
className={cn('[&_tr]:border-b', className)}
{...props}
/>
)
}
function TableBody({ className, ...props }: React.ComponentProps<'tbody'>) {
return (
<tbody
data-slot="table-body"
className={cn('[&_tr:last-child]:border-0', className)}
{...props}
/>
)
}
function TableFooter({ className, ...props }: React.ComponentProps<'tfoot'>) {
return (
<tfoot
data-slot="table-footer"
className={cn(
'bg-muted/50 border-t font-medium [&>tr]:last:border-b-0',
className,
)}
{...props}
/>
)
}
function TableRow({ className, ...props }: React.ComponentProps<'tr'>) {
return (
<tr
data-slot="table-row"
className={cn(
'hover:bg-muted/50 data-[state=selected]:bg-muted border-b transition-colors',
className,
)}
{...props}
/>
)
}
function TableHead({ className, ...props }: React.ComponentProps<'th'>) {
return (
<th
data-slot="table-head"
className={cn(
'text-foreground h-10 px-2 text-left align-middle font-medium whitespace-nowrap [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]',
className,
)}
{...props}
/>
)
}
function TableCell({ className, ...props }: React.ComponentProps<'td'>) {
return (
<td
data-slot="table-cell"
className={cn(
'p-2 align-middle whitespace-nowrap [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]',
className,
)}
{...props}
/>
)
}
function TableCaption({
className,
...props
}: React.ComponentProps<'caption'>) {
return (
<caption
data-slot="table-caption"
className={cn('text-muted-foreground mt-4 text-sm', className)}
{...props}
/>
)
}
export {
Table,
TableHeader,
TableBody,
TableFooter,
TableHead,
TableRow,
TableCell,
TableCaption,
}
+66
View File
@@ -0,0 +1,66 @@
'use client'
import * as React from 'react'
import * as TabsPrimitive from '@radix-ui/react-tabs'
import { cn } from '@/lib/utils'
function Tabs({
className,
...props
}: React.ComponentProps<typeof TabsPrimitive.Root>) {
return (
<TabsPrimitive.Root
data-slot="tabs"
className={cn('flex flex-col gap-2', className)}
{...props}
/>
)
}
function TabsList({
className,
...props
}: React.ComponentProps<typeof TabsPrimitive.List>) {
return (
<TabsPrimitive.List
data-slot="tabs-list"
className={cn(
'bg-muted text-muted-foreground inline-flex h-9 w-fit items-center justify-center rounded-lg p-[3px]',
className,
)}
{...props}
/>
)
}
function TabsTrigger({
className,
...props
}: React.ComponentProps<typeof TabsPrimitive.Trigger>) {
return (
<TabsPrimitive.Trigger
data-slot="tabs-trigger"
className={cn(
"data-[state=active]:bg-background dark:data-[state=active]:text-foreground focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:outline-ring dark:data-[state=active]:border-input dark:data-[state=active]:bg-input/30 text-foreground dark:text-muted-foreground inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 rounded-md border border-transparent px-2 py-1 text-sm font-medium whitespace-nowrap transition-[color,box-shadow] focus-visible:ring-[3px] focus-visible:outline-1 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:shadow-sm [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className,
)}
{...props}
/>
)
}
function TabsContent({
className,
...props
}: React.ComponentProps<typeof TabsPrimitive.Content>) {
return (
<TabsPrimitive.Content
data-slot="tabs-content"
className={cn('flex-1 outline-none', className)}
{...props}
/>
)
}
export { Tabs, TabsList, TabsTrigger, TabsContent }
+18
View File
@@ -0,0 +1,18 @@
import * as React from 'react'
import { cn } from '@/lib/utils'
function Textarea({ className, ...props }: React.ComponentProps<'textarea'>) {
return (
<textarea
data-slot="textarea"
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',
className,
)}
{...props}
/>
)
}
export { Textarea }
+129
View File
@@ -0,0 +1,129 @@
'use client'
import * as React from 'react'
import * as ToastPrimitives from '@radix-ui/react-toast'
import { cva, type VariantProps } from 'class-variance-authority'
import { X } from 'lucide-react'
import { cn } from '@/lib/utils'
const ToastProvider = ToastPrimitives.Provider
const ToastViewport = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Viewport>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Viewport>
>(({ className, ...props }, ref) => (
<ToastPrimitives.Viewport
ref={ref}
className={cn(
'fixed top-0 z-[100] flex max-h-screen w-full flex-col-reverse p-4 sm:bottom-0 sm:right-0 sm:top-auto sm:flex-col md:max-w-[420px]',
className,
)}
{...props}
/>
))
ToastViewport.displayName = ToastPrimitives.Viewport.displayName
const toastVariants = cva(
'group pointer-events-auto relative flex w-full items-center justify-between space-x-4 overflow-hidden rounded-md border p-6 pr-8 shadow-lg transition-all data-[swipe=cancel]:translate-x-0 data-[swipe=end]:translate-x-[var(--radix-toast-swipe-end-x)] data-[swipe=move]:translate-x-[var(--radix-toast-swipe-move-x)] data-[swipe=move]:transition-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[swipe=end]:animate-out data-[state=closed]:fade-out-80 data-[state=closed]:slide-out-to-right-full data-[state=open]:slide-in-from-top-full data-[state=open]:sm:slide-in-from-bottom-full',
{
variants: {
variant: {
default: 'border bg-background text-foreground',
destructive:
'destructive group border-destructive bg-destructive text-destructive-foreground',
},
},
defaultVariants: {
variant: 'default',
},
},
)
const Toast = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Root>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Root> &
VariantProps<typeof toastVariants>
>(({ className, variant, ...props }, ref) => {
return (
<ToastPrimitives.Root
ref={ref}
className={cn(toastVariants({ variant }), className)}
{...props}
/>
)
})
Toast.displayName = ToastPrimitives.Root.displayName
const ToastAction = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Action>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Action>
>(({ className, ...props }, ref) => (
<ToastPrimitives.Action
ref={ref}
className={cn(
'inline-flex h-8 shrink-0 items-center justify-center rounded-md border bg-transparent px-3 text-sm font-medium ring-offset-background transition-colors hover:bg-secondary focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 group-[.destructive]:border-muted/40 group-[.destructive]:hover:border-destructive/30 group-[.destructive]:hover:bg-destructive group-[.destructive]:hover:text-destructive-foreground group-[.destructive]:focus:ring-destructive',
className,
)}
{...props}
/>
))
ToastAction.displayName = ToastPrimitives.Action.displayName
const ToastClose = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Close>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Close>
>(({ className, ...props }, ref) => (
<ToastPrimitives.Close
ref={ref}
className={cn(
'absolute right-2 top-2 rounded-md p-1 text-foreground/50 opacity-0 transition-opacity hover:text-foreground focus:opacity-100 focus:outline-none focus:ring-2 group-hover:opacity-100 group-[.destructive]:text-red-300 group-[.destructive]:hover:text-red-50 group-[.destructive]:focus:ring-red-400 group-[.destructive]:focus:ring-offset-red-600',
className,
)}
toast-close=""
{...props}
>
<X className="h-4 w-4" />
</ToastPrimitives.Close>
))
ToastClose.displayName = ToastPrimitives.Close.displayName
const ToastTitle = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Title>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Title>
>(({ className, ...props }, ref) => (
<ToastPrimitives.Title
ref={ref}
className={cn('text-sm font-semibold', className)}
{...props}
/>
))
ToastTitle.displayName = ToastPrimitives.Title.displayName
const ToastDescription = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Description>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Description>
>(({ className, ...props }, ref) => (
<ToastPrimitives.Description
ref={ref}
className={cn('text-sm opacity-90', className)}
{...props}
/>
))
ToastDescription.displayName = ToastPrimitives.Description.displayName
type ToastProps = React.ComponentPropsWithoutRef<typeof Toast>
type ToastActionElement = React.ReactElement<typeof ToastAction>
export {
type ToastProps,
type ToastActionElement,
ToastProvider,
ToastViewport,
Toast,
ToastTitle,
ToastDescription,
ToastClose,
ToastAction,
}
+35
View File
@@ -0,0 +1,35 @@
'use client'
import { useToast } from '@/hooks/use-toast'
import {
Toast,
ToastClose,
ToastDescription,
ToastProvider,
ToastTitle,
ToastViewport,
} from '@/components/ui/toast'
export function Toaster() {
const { toasts } = useToast()
return (
<ToastProvider>
{toasts.map(function ({ id, title, description, action, ...props }) {
return (
<Toast key={id} {...props}>
<div className="grid gap-1">
{title && <ToastTitle>{title}</ToastTitle>}
{description && (
<ToastDescription>{description}</ToastDescription>
)}
</div>
{action}
<ToastClose />
</Toast>
)
})}
<ToastViewport />
</ToastProvider>
)
}
+73
View File
@@ -0,0 +1,73 @@
'use client'
import * as React from 'react'
import * as ToggleGroupPrimitive from '@radix-ui/react-toggle-group'
import { type VariantProps } from 'class-variance-authority'
import { cn } from '@/lib/utils'
import { toggleVariants } from '@/components/ui/toggle'
const ToggleGroupContext = React.createContext<
VariantProps<typeof toggleVariants>
>({
size: 'default',
variant: 'default',
})
function ToggleGroup({
className,
variant,
size,
children,
...props
}: React.ComponentProps<typeof ToggleGroupPrimitive.Root> &
VariantProps<typeof toggleVariants>) {
return (
<ToggleGroupPrimitive.Root
data-slot="toggle-group"
data-variant={variant}
data-size={size}
className={cn(
'group/toggle-group flex w-fit items-center rounded-md data-[variant=outline]:shadow-xs',
className,
)}
{...props}
>
<ToggleGroupContext.Provider value={{ variant, size }}>
{children}
</ToggleGroupContext.Provider>
</ToggleGroupPrimitive.Root>
)
}
function ToggleGroupItem({
className,
children,
variant,
size,
...props
}: React.ComponentProps<typeof ToggleGroupPrimitive.Item> &
VariantProps<typeof toggleVariants>) {
const context = React.useContext(ToggleGroupContext)
return (
<ToggleGroupPrimitive.Item
data-slot="toggle-group-item"
data-variant={context.variant || variant}
data-size={context.size || size}
className={cn(
toggleVariants({
variant: context.variant || variant,
size: context.size || size,
}),
'min-w-0 flex-1 shrink-0 rounded-none shadow-none first:rounded-l-md last:rounded-r-md focus:z-10 focus-visible:z-10 data-[variant=outline]:border-l-0 data-[variant=outline]:first:border-l',
className,
)}
{...props}
>
{children}
</ToggleGroupPrimitive.Item>
)
}
export { ToggleGroup, ToggleGroupItem }
+47
View File
@@ -0,0 +1,47 @@
'use client'
import * as React from 'react'
import * as TogglePrimitive from '@radix-ui/react-toggle'
import { cva, type VariantProps } from 'class-variance-authority'
import { cn } from '@/lib/utils'
const toggleVariants = cva(
"inline-flex items-center justify-center gap-2 rounded-md text-sm font-medium hover:bg-muted hover:text-muted-foreground disabled:pointer-events-none disabled:opacity-50 data-[state=on]:bg-accent data-[state=on]:text-accent-foreground [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 [&_svg]:shrink-0 focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] outline-none transition-[color,box-shadow] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive whitespace-nowrap",
{
variants: {
variant: {
default: 'bg-transparent',
outline:
'border border-input bg-transparent shadow-xs hover:bg-accent hover:text-accent-foreground',
},
size: {
default: 'h-9 px-2 min-w-9',
sm: 'h-8 px-1.5 min-w-8',
lg: 'h-10 px-2.5 min-w-10',
},
},
defaultVariants: {
variant: 'default',
size: 'default',
},
},
)
function Toggle({
className,
variant,
size,
...props
}: React.ComponentProps<typeof TogglePrimitive.Root> &
VariantProps<typeof toggleVariants>) {
return (
<TogglePrimitive.Root
data-slot="toggle"
className={cn(toggleVariants({ variant, size, className }))}
{...props}
/>
)
}
export { Toggle, toggleVariants }
+61
View File
@@ -0,0 +1,61 @@
'use client'
import * as React from 'react'
import * as TooltipPrimitive from '@radix-ui/react-tooltip'
import { cn } from '@/lib/utils'
function TooltipProvider({
delayDuration = 0,
...props
}: React.ComponentProps<typeof TooltipPrimitive.Provider>) {
return (
<TooltipPrimitive.Provider
data-slot="tooltip-provider"
delayDuration={delayDuration}
{...props}
/>
)
}
function Tooltip({
...props
}: React.ComponentProps<typeof TooltipPrimitive.Root>) {
return (
<TooltipProvider>
<TooltipPrimitive.Root data-slot="tooltip" {...props} />
</TooltipProvider>
)
}
function TooltipTrigger({
...props
}: React.ComponentProps<typeof TooltipPrimitive.Trigger>) {
return <TooltipPrimitive.Trigger data-slot="tooltip-trigger" {...props} />
}
function TooltipContent({
className,
sideOffset = 0,
children,
...props
}: React.ComponentProps<typeof TooltipPrimitive.Content>) {
return (
<TooltipPrimitive.Portal>
<TooltipPrimitive.Content
data-slot="tooltip-content"
sideOffset={sideOffset}
className={cn(
'bg-foreground text-background animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-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 w-fit origin-(--radix-tooltip-content-transform-origin) rounded-md px-3 py-1.5 text-xs text-balance',
className,
)}
{...props}
>
{children}
<TooltipPrimitive.Arrow className="bg-foreground fill-foreground z-50 size-2.5 translate-y-[calc(-50%_-_2px)] rotate-45 rounded-[2px]" />
</TooltipPrimitive.Content>
</TooltipPrimitive.Portal>
)
}
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }
+19
View File
@@ -0,0 +1,19 @@
import * as React from 'react'
const MOBILE_BREAKPOINT = 768
export function useIsMobile() {
const [isMobile, setIsMobile] = React.useState<boolean | undefined>(undefined)
React.useEffect(() => {
const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`)
const onChange = () => {
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT)
}
mql.addEventListener('change', onChange)
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT)
return () => mql.removeEventListener('change', onChange)
}, [])
return !!isMobile
}
+191
View File
@@ -0,0 +1,191 @@
'use client'
// Inspired by react-hot-toast library
import * as React from 'react'
import type { ToastActionElement, ToastProps } from '@/components/ui/toast'
const TOAST_LIMIT = 1
const TOAST_REMOVE_DELAY = 1000000
type ToasterToast = ToastProps & {
id: string
title?: React.ReactNode
description?: React.ReactNode
action?: ToastActionElement
}
const actionTypes = {
ADD_TOAST: 'ADD_TOAST',
UPDATE_TOAST: 'UPDATE_TOAST',
DISMISS_TOAST: 'DISMISS_TOAST',
REMOVE_TOAST: 'REMOVE_TOAST',
} as const
let count = 0
function genId() {
count = (count + 1) % Number.MAX_SAFE_INTEGER
return count.toString()
}
type ActionType = typeof actionTypes
type Action =
| {
type: ActionType['ADD_TOAST']
toast: ToasterToast
}
| {
type: ActionType['UPDATE_TOAST']
toast: Partial<ToasterToast>
}
| {
type: ActionType['DISMISS_TOAST']
toastId?: ToasterToast['id']
}
| {
type: ActionType['REMOVE_TOAST']
toastId?: ToasterToast['id']
}
interface State {
toasts: ToasterToast[]
}
const toastTimeouts = new Map<string, ReturnType<typeof setTimeout>>()
const addToRemoveQueue = (toastId: string) => {
if (toastTimeouts.has(toastId)) {
return
}
const timeout = setTimeout(() => {
toastTimeouts.delete(toastId)
dispatch({
type: 'REMOVE_TOAST',
toastId: toastId,
})
}, TOAST_REMOVE_DELAY)
toastTimeouts.set(toastId, timeout)
}
export const reducer = (state: State, action: Action): State => {
switch (action.type) {
case 'ADD_TOAST':
return {
...state,
toasts: [action.toast, ...state.toasts].slice(0, TOAST_LIMIT),
}
case 'UPDATE_TOAST':
return {
...state,
toasts: state.toasts.map((t) =>
t.id === action.toast.id ? { ...t, ...action.toast } : t,
),
}
case 'DISMISS_TOAST': {
const { toastId } = action
// ! Side effects ! - This could be extracted into a dismissToast() action,
// but I'll keep it here for simplicity
if (toastId) {
addToRemoveQueue(toastId)
} else {
state.toasts.forEach((toast) => {
addToRemoveQueue(toast.id)
})
}
return {
...state,
toasts: state.toasts.map((t) =>
t.id === toastId || toastId === undefined
? {
...t,
open: false,
}
: t,
),
}
}
case 'REMOVE_TOAST':
if (action.toastId === undefined) {
return {
...state,
toasts: [],
}
}
return {
...state,
toasts: state.toasts.filter((t) => t.id !== action.toastId),
}
}
}
const listeners: Array<(state: State) => void> = []
let memoryState: State = { toasts: [] }
function dispatch(action: Action) {
memoryState = reducer(memoryState, action)
listeners.forEach((listener) => {
listener(memoryState)
})
}
type Toast = Omit<ToasterToast, 'id'>
function toast({ ...props }: Toast) {
const id = genId()
const update = (props: ToasterToast) =>
dispatch({
type: 'UPDATE_TOAST',
toast: { ...props, id },
})
const dismiss = () => dispatch({ type: 'DISMISS_TOAST', toastId: id })
dispatch({
type: 'ADD_TOAST',
toast: {
...props,
id,
open: true,
onOpenChange: (open) => {
if (!open) dismiss()
},
},
})
return {
id: id,
dismiss,
update,
}
}
function useToast() {
const [state, setState] = React.useState<State>(memoryState)
React.useEffect(() => {
listeners.push(setState)
return () => {
const index = listeners.indexOf(setState)
if (index > -1) {
listeners.splice(index, 1)
}
}
}, [state])
return {
...state,
toast,
dismiss: (toastId?: string) => dispatch({ type: 'DISMISS_TOAST', toastId }),
}
}
export { useToast, toast }
+19
View File
@@ -0,0 +1,19 @@
import * as React from 'react'
const MOBILE_BREAKPOINT = 768
export function useIsMobile() {
const [isMobile, setIsMobile] = React.useState<boolean | undefined>(undefined)
React.useEffect(() => {
const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`)
const onChange = () => {
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT)
}
mql.addEventListener('change', onChange)
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT)
return () => mql.removeEventListener('change', onChange)
}, [])
return !!isMobile
}
+191
View File
@@ -0,0 +1,191 @@
'use client'
// Inspired by react-hot-toast library
import * as React from 'react'
import type { ToastActionElement, ToastProps } from '@/components/ui/toast'
const TOAST_LIMIT = 1
const TOAST_REMOVE_DELAY = 1000000
type ToasterToast = ToastProps & {
id: string
title?: React.ReactNode
description?: React.ReactNode
action?: ToastActionElement
}
const actionTypes = {
ADD_TOAST: 'ADD_TOAST',
UPDATE_TOAST: 'UPDATE_TOAST',
DISMISS_TOAST: 'DISMISS_TOAST',
REMOVE_TOAST: 'REMOVE_TOAST',
} as const
let count = 0
function genId() {
count = (count + 1) % Number.MAX_SAFE_INTEGER
return count.toString()
}
type ActionType = typeof actionTypes
type Action =
| {
type: ActionType['ADD_TOAST']
toast: ToasterToast
}
| {
type: ActionType['UPDATE_TOAST']
toast: Partial<ToasterToast>
}
| {
type: ActionType['DISMISS_TOAST']
toastId?: ToasterToast['id']
}
| {
type: ActionType['REMOVE_TOAST']
toastId?: ToasterToast['id']
}
interface State {
toasts: ToasterToast[]
}
const toastTimeouts = new Map<string, ReturnType<typeof setTimeout>>()
const addToRemoveQueue = (toastId: string) => {
if (toastTimeouts.has(toastId)) {
return
}
const timeout = setTimeout(() => {
toastTimeouts.delete(toastId)
dispatch({
type: 'REMOVE_TOAST',
toastId: toastId,
})
}, TOAST_REMOVE_DELAY)
toastTimeouts.set(toastId, timeout)
}
export const reducer = (state: State, action: Action): State => {
switch (action.type) {
case 'ADD_TOAST':
return {
...state,
toasts: [action.toast, ...state.toasts].slice(0, TOAST_LIMIT),
}
case 'UPDATE_TOAST':
return {
...state,
toasts: state.toasts.map((t) =>
t.id === action.toast.id ? { ...t, ...action.toast } : t,
),
}
case 'DISMISS_TOAST': {
const { toastId } = action
// ! Side effects ! - This could be extracted into a dismissToast() action,
// but I'll keep it here for simplicity
if (toastId) {
addToRemoveQueue(toastId)
} else {
state.toasts.forEach((toast) => {
addToRemoveQueue(toast.id)
})
}
return {
...state,
toasts: state.toasts.map((t) =>
t.id === toastId || toastId === undefined
? {
...t,
open: false,
}
: t,
),
}
}
case 'REMOVE_TOAST':
if (action.toastId === undefined) {
return {
...state,
toasts: [],
}
}
return {
...state,
toasts: state.toasts.filter((t) => t.id !== action.toastId),
}
}
}
const listeners: Array<(state: State) => void> = []
let memoryState: State = { toasts: [] }
function dispatch(action: Action) {
memoryState = reducer(memoryState, action)
listeners.forEach((listener) => {
listener(memoryState)
})
}
type Toast = Omit<ToasterToast, 'id'>
function toast({ ...props }: Toast) {
const id = genId()
const update = (props: ToasterToast) =>
dispatch({
type: 'UPDATE_TOAST',
toast: { ...props, id },
})
const dismiss = () => dispatch({ type: 'DISMISS_TOAST', toastId: id })
dispatch({
type: 'ADD_TOAST',
toast: {
...props,
id,
open: true,
onOpenChange: (open) => {
if (!open) dismiss()
},
},
})
return {
id: id,
dismiss,
update,
}
}
function useToast() {
const [state, setState] = React.useState<State>(memoryState)
React.useEffect(() => {
listeners.push(setState)
return () => {
const index = listeners.indexOf(setState)
if (index > -1) {
listeners.splice(index, 1)
}
}
}, [state])
return {
...state,
toast,
dismiss: (toastId?: string) => dispatch({ type: 'DISMISS_TOAST', toastId }),
}
}
export { useToast, toast }
+874
View File
@@ -0,0 +1,874 @@
'use server'
import { createClient } from '@/lib/supabase/server'
import { redirect } from 'next/navigation'
import { revalidatePath } from 'next/cache'
// ─── Auth ────────────────────────────────────────────────────────────────────
export async function signIn(email: string, password: string) {
const supabase = await createClient()
const { error } = await supabase.auth.signInWithPassword({ email, password })
if (error) return { error: error.message }
return { success: true }
}
export async function signOut() {
const supabase = await createClient()
await supabase.auth.signOut()
redirect('/login')
}
export async function getUser() {
const supabase = await createClient()
const { data: { user } } = await supabase.auth.getUser()
return user
}
export async function getCurrentMember() {
const supabase = await createClient()
const { data: { user } } = await supabase.auth.getUser()
if (!user) return null
const { data } = await supabase
.from('space_members')
.select('*, spaces(*)')
.eq('user_id', user.id)
.eq('status', 'current')
.single()
return data
}
// ─── Tasks ───────────────────────────────────────────────────────────────────
export async function createTask(formData: {
title: string
description?: string
type: string
area?: string
recurrence?: string
due_date?: string
}) {
const supabase = await createClient()
const { data: { user } } = await supabase.auth.getUser()
if (!user) return { error: 'Not authenticated' }
const { data: member } = await supabase
.from('space_members').select('space_id, display_name').eq('user_id', user.id).eq('status', 'current').single()
if (!member) return { error: 'No active membership' }
const { data, error } = await supabase.from('tasks').insert({
space_id: member.space_id,
title: formData.title,
description: formData.description,
type: formData.type || 'task',
area: formData.area,
recurrence: formData.recurrence || 'none',
due_date: formData.due_date || null,
status: 'open',
requested_by: user.id,
requested_by_name: member.display_name,
}).select().single()
if (error) return { error: error.message }
// Log activity
await supabase.from('activity_log').insert({
space_id: member.space_id,
user_id: user.id,
display_name: member.display_name,
action: 'created',
entity_type: 'task',
entity_id: data.id,
details: formData.title,
})
revalidatePath('/tasks')
revalidatePath('/dashboard')
return { data }
}
export async function claimTask(taskId: string) {
const supabase = await createClient()
const { data: { user } } = await supabase.auth.getUser()
if (!user) return { error: 'Not authenticated' }
const { data: member } = await supabase
.from('space_members').select('space_id, display_name').eq('user_id', user.id).eq('status', 'current').single()
if (!member) return { error: 'No active membership' }
const { error } = await supabase.from('tasks').update({
claimed_by: user.id,
claimed_by_name: member.display_name,
status: 'claimed',
}).eq('id', taskId).eq('space_id', member.space_id)
if (error) return { error: error.message }
await supabase.from('activity_log').insert({
space_id: member.space_id,
user_id: user.id,
display_name: member.display_name,
action: 'claimed',
entity_type: 'task',
entity_id: taskId,
})
revalidatePath('/tasks')
revalidatePath('/dashboard')
return { success: true }
}
export async function completeTask(taskId: string) {
const supabase = await createClient()
const { data: { user } } = await supabase.auth.getUser()
if (!user) return { error: 'Not authenticated' }
const { data: member } = await supabase
.from('space_members').select('space_id, display_name').eq('user_id', user.id).eq('status', 'current').single()
if (!member) return { error: 'No active membership' }
const { error } = await supabase.from('tasks').update({
status: 'completed',
completed_at: new Date().toISOString(),
last_done_at: new Date().toISOString(),
}).eq('id', taskId).eq('space_id', member.space_id)
if (error) return { error: error.message }
await supabase.from('activity_log').insert({
space_id: member.space_id,
user_id: user.id,
display_name: member.display_name,
action: 'completed',
entity_type: 'task',
entity_id: taskId,
})
revalidatePath('/tasks')
revalidatePath('/dashboard')
return { success: true }
}
export async function deleteTask(taskId: string) {
const supabase = await createClient()
const { data: { user } } = await supabase.auth.getUser()
if (!user) return { error: 'Not authenticated' }
const { data: member } = await supabase
.from('space_members').select('space_id').eq('user_id', user.id).eq('status', 'current').single()
if (!member) return { error: 'No active membership' }
const { error } = await supabase.from('tasks').delete()
.eq('id', taskId).eq('space_id', member.space_id)
if (error) return { error: error.message }
revalidatePath('/tasks')
return { success: true }
}
// ─── Projects ────────────────────────────────────────────────────────────────
export async function createProject(formData: {
title: string
description?: string
area?: string
tags?: string[]
due_date?: string
}) {
const supabase = await createClient()
const { data: { user } } = await supabase.auth.getUser()
if (!user) return { error: 'Not authenticated' }
const { data: member } = await supabase
.from('space_members').select('space_id, display_name').eq('user_id', user.id).eq('status', 'current').single()
if (!member) return { error: 'No active membership' }
const { data, error } = await supabase.from('projects').insert({
space_id: member.space_id,
title: formData.title,
description: formData.description,
area: formData.area,
tags: formData.tags ?? [],
due_date: formData.due_date || null,
status: 'backlog',
}).select().single()
if (error) return { error: error.message }
await supabase.from('activity_log').insert({
space_id: member.space_id,
user_id: user.id,
display_name: member.display_name,
action: 'created',
entity_type: 'project',
entity_id: data.id,
details: formData.title,
})
revalidatePath('/projects')
return { data }
}
export async function updateProjectStatus(projectId: string, status: string) {
const supabase = await createClient()
const { data: { user } } = await supabase.auth.getUser()
if (!user) return { error: 'Not authenticated' }
const { data: member } = await supabase
.from('space_members').select('space_id').eq('user_id', user.id).eq('status', 'current').single()
if (!member) return { error: 'No active membership' }
const { error } = await supabase.from('projects').update({ status, updated_at: new Date().toISOString() })
.eq('id', projectId).eq('space_id', member.space_id)
if (error) return { error: error.message }
revalidatePath('/projects')
revalidatePath('/dashboard')
return { success: true }
}
export async function deleteProject(projectId: string) {
const supabase = await createClient()
const { data: { user } } = await supabase.auth.getUser()
if (!user) return { error: 'Not authenticated' }
const { data: member } = await supabase
.from('space_members').select('space_id').eq('user_id', user.id).eq('status', 'current').single()
if (!member) return { error: 'No active membership' }
const { error } = await supabase.from('projects').delete()
.eq('id', projectId).eq('space_id', member.space_id)
if (error) return { error: error.message }
revalidatePath('/projects')
return { success: true }
}
// ─── Members ─────────────────────────────────────────────────────────────────
export async function addMember(formData: {
display_name: string
email: string
phone?: string
handle?: string
tier: string
role: string
joined_at?: string
has_card_access?: boolean
}) {
const supabase = await createClient()
const { data: { user } } = await supabase.auth.getUser()
if (!user) return { error: 'Not authenticated' }
const { data: self } = await supabase
.from('space_members').select('space_id, role').eq('user_id', user.id).eq('status', 'current').single()
if (!self || (self.role !== 'admin' && self.role !== 'board')) return { error: 'Admin access required' }
const { data, error } = await supabase.from('space_members').insert({
space_id: self.space_id,
display_name: formData.display_name,
email: formData.email,
phone: formData.phone,
handle: formData.handle,
tier: formData.tier,
role: formData.role,
status: 'current',
approved: true,
joined_at: formData.joined_at || new Date().toISOString(),
has_card_access: formData.has_card_access ?? false,
}).select().single()
if (error) return { error: error.message }
revalidatePath('/members')
return { data }
}
export async function updateMember(memberId: string, updates: {
display_name?: string
email?: string
phone?: string
handle?: string
tier?: string
role?: string
status?: string
has_card_access?: boolean
payment_status?: string
payment_note?: string
}) {
const supabase = await createClient()
const { data: { user } } = await supabase.auth.getUser()
if (!user) return { error: 'Not authenticated' }
const { data: self } = await supabase
.from('space_members').select('space_id, role').eq('user_id', user.id).eq('status', 'current').single()
if (!self || (self.role !== 'admin' && self.role !== 'board')) return { error: 'Admin access required' }
const { error } = await supabase.from('space_members').update(updates)
.eq('id', memberId).eq('space_id', self.space_id)
if (error) return { error: error.message }
revalidatePath('/members')
return { success: true }
}
export async function approveMember(memberId: string) {
const supabase = await createClient()
const { data: { user } } = await supabase.auth.getUser()
if (!user) return { error: 'Not authenticated' }
const { data: self } = await supabase
.from('space_members').select('space_id, role, display_name').eq('user_id', user.id).eq('status', 'current').single()
if (!self || (self.role !== 'admin' && self.role !== 'board')) return { error: 'Admin access required' }
const { error } = await supabase.from('space_members').update({ status: 'current', approved: true })
.eq('id', memberId).eq('space_id', self.space_id)
if (error) return { error: error.message }
await supabase.from('activity_log').insert({
space_id: self.space_id,
user_id: user.id,
display_name: self.display_name,
action: 'approved',
entity_type: 'member',
entity_id: memberId,
})
revalidatePath('/members')
return { success: true }
}
export async function removeMember(memberId: string) {
const supabase = await createClient()
const { data: { user } } = await supabase.auth.getUser()
if (!user) return { error: 'Not authenticated' }
const { data: self } = await supabase
.from('space_members').select('space_id, role').eq('user_id', user.id).eq('status', 'current').single()
if (!self || self.role !== 'admin') return { error: 'Admin access required' }
const { error } = await supabase.from('space_members').delete()
.eq('id', memberId).eq('space_id', self.space_id)
if (error) return { error: error.message }
revalidatePath('/members')
return { success: true }
}
// ─── Contacts ────────────────────────────────────────────────────────────────
export async function createContact(formData: {
name: string
contact_type: string
email?: string
phone?: string
details?: string
note?: string
group_label?: string
tags?: string[]
}) {
const supabase = await createClient()
const { data: { user } } = await supabase.auth.getUser()
if (!user) return { error: 'Not authenticated' }
const { data: member } = await supabase
.from('space_members').select('space_id').eq('user_id', user.id).eq('status', 'current').single()
if (!member) return { error: 'No active membership' }
const code = formData.name.slice(0, 3).toUpperCase() + Math.floor(Math.random() * 900 + 100)
const { data, error } = await supabase.from('contacts').insert({
space_id: member.space_id,
name: formData.name,
contact_type: formData.contact_type,
email: formData.email,
phone: formData.phone,
details: formData.details || formData.note,
note: formData.note,
group_label: formData.group_label,
tags: formData.tags ?? [],
code,
}).select().single()
if (error) return { error: error.message }
revalidatePath('/contacts')
return { data }
}
export async function updateContact(contactId: string, updates: {
name?: string
contact_type?: string
email?: string
phone?: string
details?: string
note?: string
group_label?: string
tags?: string[]
}) {
const supabase = await createClient()
const { data: { user } } = await supabase.auth.getUser()
if (!user) return { error: 'Not authenticated' }
const { data: member } = await supabase
.from('space_members').select('space_id').eq('user_id', user.id).eq('status', 'current').single()
if (!member) return { error: 'No active membership' }
const { error } = await supabase.from('contacts').update({ ...updates, updated_at: new Date().toISOString() })
.eq('id', contactId).eq('space_id', member.space_id)
if (error) return { error: error.message }
revalidatePath('/contacts')
return { success: true }
}
export async function deleteContact(contactId: string) {
const supabase = await createClient()
const { data: { user } } = await supabase.auth.getUser()
if (!user) return { error: 'Not authenticated' }
const { data: member } = await supabase
.from('space_members').select('space_id').eq('user_id', user.id).eq('status', 'current').single()
if (!member) return { error: 'No active membership' }
const { error } = await supabase.from('contacts').delete()
.eq('id', contactId).eq('space_id', member.space_id)
if (error) return { error: error.message }
revalidatePath('/contacts')
return { success: true }
}
// ─── Payments ────────────────────────────────────────────────────────────────
export async function logCashPayment(formData: {
amount: number
from_note: string
member_id?: string
transaction_date?: string
}) {
const supabase = await createClient()
const { data: { user } } = await supabase.auth.getUser()
if (!user) return { error: 'Not authenticated' }
const { data: member } = await supabase
.from('space_members').select('space_id, role, display_name').eq('user_id', user.id).eq('status', 'current').single()
if (!member || (member.role !== 'admin' && member.role !== 'board' && member.role !== 'treasurer')) {
return { error: 'Treasurer access required' }
}
const { data, error } = await supabase.from('payments').insert({
space_id: member.space_id,
platform: 'cash',
amount: formData.amount,
from_identifier: 'Cash',
from_note: formData.from_note,
member_id: formData.member_id || null,
link_status: formData.member_id ? 'linked' : 'unlinked',
transaction_date: formData.transaction_date || new Date().toISOString(),
}).select().single()
if (error) return { error: error.message }
// Update member's last_paid_at if linked
if (formData.member_id) {
await supabase.from('space_members').update({
last_paid_at: formData.transaction_date || new Date().toISOString(),
payment_status: 'current',
}).eq('id', formData.member_id).eq('space_id', member.space_id)
}
await supabase.from('activity_log').insert({
space_id: member.space_id,
user_id: user.id,
display_name: member.display_name,
action: 'logged',
entity_type: 'payment',
entity_id: data.id,
details: `$${formData.amount} cash — ${formData.from_note}`,
})
revalidatePath('/payments')
revalidatePath('/dashboard')
return { data }
}
export async function linkPaymentToMember(paymentId: string, memberId: string) {
const supabase = await createClient()
const { data: { user } } = await supabase.auth.getUser()
if (!user) return { error: 'Not authenticated' }
const { data: self } = await supabase
.from('space_members').select('space_id, role').eq('user_id', user.id).eq('status', 'current').single()
if (!self || (self.role !== 'admin' && self.role !== 'board' && self.role !== 'treasurer')) {
return { error: 'Treasurer access required' }
}
// Get payment to find the amount and date
const { data: payment } = await supabase
.from('payments').select('amount, transaction_date').eq('id', paymentId).single()
const { error } = await supabase.from('payments').update({
member_id: memberId,
link_status: 'linked',
}).eq('id', paymentId).eq('space_id', self.space_id)
if (error) return { error: error.message }
// Update member payment status
if (payment) {
await supabase.from('space_members').update({
last_paid_at: payment.transaction_date,
payment_status: 'current',
}).eq('id', memberId).eq('space_id', self.space_id)
}
revalidatePath('/payments')
revalidatePath('/members')
revalidatePath('/dashboard')
return { success: true }
}
export async function importPaymentsCsv(rows: Array<{
platform: string
amount: number
from_identifier: string
from_note?: string
transaction_date: string
}>) {
const supabase = await createClient()
const { data: { user } } = await supabase.auth.getUser()
if (!user) return { error: 'Not authenticated' }
const { data: member } = await supabase
.from('space_members').select('space_id, role').eq('user_id', user.id).eq('status', 'current').single()
if (!member || (member.role !== 'admin' && member.role !== 'board' && member.role !== 'treasurer')) {
return { error: 'Treasurer access required' }
}
const inserts = rows.map(r => ({
space_id: member.space_id,
platform: r.platform as any,
amount: r.amount,
from_identifier: r.from_identifier,
from_note: r.from_note,
link_status: 'unlinked' as const,
transaction_date: r.transaction_date,
}))
const { data, error } = await supabase.from('payments').insert(inserts).select()
if (error) return { error: error.message }
revalidatePath('/payments')
revalidatePath('/dashboard')
return { data, count: data.length }
}
// ─── Knowledge Base ───────────────────────────────────────────────────────────
export async function createKbEntry(formData: {
title: string
content: string
description?: string
area?: string
visibility?: string
is_pinned?: boolean
tags?: string[]
icon?: string
}) {
const supabase = await createClient()
const { data: { user } } = await supabase.auth.getUser()
if (!user) return { error: 'Not authenticated' }
const { data: member } = await supabase
.from('space_members').select('space_id, display_name, handle').eq('user_id', user.id).eq('status', 'current').single()
if (!member) return { error: 'No active membership' }
const { data, error } = await supabase.from('knowledge_base').insert({
space_id: member.space_id,
title: formData.title,
content: formData.content,
area: formData.area,
visibility: formData.visibility || 'all_members',
is_pinned: formData.is_pinned ?? false,
tags: formData.tags ?? [],
icon: formData.icon,
updated_by_id: user.id,
updated_by_name: member.display_name,
}).select().single()
if (error) return { error: error.message }
revalidatePath('/ops')
return { data }
}
export async function updateKbEntry(entryId: string, updates: {
title?: string
content?: string
area?: string
visibility?: string
is_pinned?: boolean
tags?: string[]
}) {
const supabase = await createClient()
const { data: { user } } = await supabase.auth.getUser()
if (!user) return { error: 'Not authenticated' }
const { data: member } = await supabase
.from('space_members').select('space_id, display_name').eq('user_id', user.id).eq('status', 'current').single()
if (!member) return { error: 'No active membership' }
// Only pass columns that actually exist in the DB
const { is_pinned, ...rest } = updates
const { error } = await supabase.from('knowledge_base').update({
...rest,
...(is_pinned !== undefined ? { is_pinned } : {}),
updated_by_id: user.id,
updated_by_name: member.display_name,
updated_at: new Date().toISOString(),
}).eq('id', entryId).eq('space_id', member.space_id)
if (error) return { error: error.message }
revalidatePath('/ops')
return { success: true }
}
export async function deleteKbEntry(entryId: string) {
const supabase = await createClient()
const { data: { user } } = await supabase.auth.getUser()
if (!user) return { error: 'Not authenticated' }
const { data: member } = await supabase
.from('space_members').select('space_id').eq('user_id', user.id).eq('status', 'current').single()
if (!member) return { error: 'No active membership' }
const { error } = await supabase.from('knowledge_base').delete()
.eq('id', entryId).eq('space_id', member.space_id)
if (error) return { error: error.message }
revalidatePath('/ops')
return { success: true }
}
// ─── Secrets ─────────────────────────────────────────────────────────────────
export async function createSecret(formData: {
title: string
value: string
description?: string
area?: string
icon?: string
}) {
const supabase = await createClient()
const { data: { user } } = await supabase.auth.getUser()
if (!user) return { error: 'Not authenticated' }
const { data: member } = await supabase
.from('space_members').select('space_id, role').eq('user_id', user.id).eq('status', 'current').single()
if (!member || (member.role !== 'admin' && member.role !== 'board')) return { error: 'Admin access required' }
const { data, error } = await supabase.from('secrets').insert({
space_id: member.space_id,
title: formData.title,
value: formData.value,
description: formData.description,
area: formData.area,
icon: formData.icon,
}).select().single()
if (error) return { error: error.message }
revalidatePath('/ops')
return { data }
}
export async function deleteSecret(secretId: string) {
const supabase = await createClient()
const { data: { user } } = await supabase.auth.getUser()
if (!user) return { error: 'Not authenticated' }
const { data: member } = await supabase
.from('space_members').select('space_id, role').eq('user_id', user.id).eq('status', 'current').single()
if (!member || member.role !== 'admin') return { error: 'Admin access required' }
const { error } = await supabase.from('secrets').delete()
.eq('id', secretId).eq('space_id', member.space_id)
if (error) return { error: error.message }
revalidatePath('/ops')
return { success: true }
}
// ─── Area Leads ───────────────────────────────────────────────────────────────
export async function upsertAreaLead(formData: {
area_code: string
area_name: string
lead_id?: string
lead_handle?: string
status?: string
}) {
const supabase = await createClient()
const { data: { user } } = await supabase.auth.getUser()
if (!user) return { error: 'Not authenticated' }
const { data: member } = await supabase
.from('space_members').select('space_id, role').eq('user_id', user.id).eq('status', 'current').single()
if (!member || (member.role !== 'admin' && member.role !== 'board')) return { error: 'Admin access required' }
const { data, error } = await supabase.from('area_leads').upsert({
space_id: member.space_id,
area_code: formData.area_code,
area_name: formData.area_name,
lead_id: formData.lead_id || null,
lead_handle: formData.lead_handle || null,
status: formData.status || 'active',
updated_at: new Date().toISOString(),
}, { onConflict: 'space_id,area_code' }).select().single()
if (error) return { error: error.message }
revalidatePath('/ops')
return { data }
}
// ─── Settings ────────────────────────────────────────────────────────────────
export async function updateSpaceSettings(updates: {
name?: string
slug?: string
city?: string
require_approval?: boolean
public_member_directory?: boolean
}) {
const supabase = await createClient()
const { data: { user } } = await supabase.auth.getUser()
if (!user) return { error: 'Not authenticated' }
const { data: member } = await supabase
.from('space_members').select('space_id, role').eq('user_id', user.id).eq('status', 'current').single()
if (!member || member.role !== 'admin') return { error: 'Admin access required' }
const { error } = await supabase.from('spaces').update({
...updates,
updated_at: new Date().toISOString(),
}).eq('id', member.space_id)
if (error) return { error: error.message }
revalidatePath('/settings')
return { success: true }
}
export async function rotateWebhookSecret() {
const supabase = await createClient()
const { data: { user } } = await supabase.auth.getUser()
if (!user) return { error: 'Not authenticated' }
const { data: member } = await supabase
.from('space_members').select('space_id, role').eq('user_id', user.id).eq('status', 'current').single()
if (!member || member.role !== 'admin') return { error: 'Admin access required' }
// Generate a new webhook secret
const newSecret = 'whsec_' + Array.from(crypto.getRandomValues(new Uint8Array(24)))
.map(b => b.toString(16).padStart(2, '0')).join('')
const { error } = await supabase.from('spaces').update({ webhook_secret: newSecret, updated_at: new Date().toISOString() })
.eq('id', member.space_id)
if (error) return { error: error.message }
revalidatePath('/settings')
return { secret: newSecret }
}
export async function saveIntegration(platform: string, config: Record<string, string>) {
const supabase = await createClient()
const { data: { user } } = await supabase.auth.getUser()
if (!user) return { error: 'Not authenticated' }
const { data: member } = await supabase
.from('space_members').select('space_id, role').eq('user_id', user.id).eq('status', 'current').single()
if (!member || member.role !== 'admin') return { error: 'Admin access required' }
// Mask sensitive values — store but indicate they're set
const safeConfig: Record<string, string> = {}
for (const [key, value] of Object.entries(config)) {
safeConfig[key] = value ? value : ''
safeConfig[`${key}_set`] = value ? 'true' : 'false'
}
const { error } = await supabase.from('integrations').upsert({
space_id: member.space_id,
platform,
name: platform,
is_connected: Object.values(config).some(v => v.length > 0),
config: safeConfig,
updated_at: new Date().toISOString(),
}, { onConflict: 'space_id,platform' })
if (error) return { error: error.message }
revalidatePath('/settings')
return { success: true }
}
export async function disconnectIntegration(platform: string) {
const supabase = await createClient()
const { data: { user } } = await supabase.auth.getUser()
if (!user) return { error: 'Not authenticated' }
const { data: member } = await supabase
.from('space_members').select('space_id, role').eq('user_id', user.id).eq('status', 'current').single()
if (!member || member.role !== 'admin') return { error: 'Admin access required' }
const { error } = await supabase.from('integrations').update({
is_connected: false,
config: {},
updated_at: new Date().toISOString(),
}).eq('space_id', member.space_id).eq('platform', platform)
if (error) return { error: error.message }
revalidatePath('/settings')
return { success: true }
}
// ─── Import Members ───────────────────────────────────────────────────────────
export async function importMembers(rows: Array<{
display_name: string
email: string
phone?: string
tier?: string
joined_at?: string
last_paid_at?: string
has_card_access?: boolean
}>) {
const supabase = await createClient()
const { data: { user } } = await supabase.auth.getUser()
if (!user) return { error: 'Not authenticated' }
const { data: member } = await supabase
.from('space_members').select('space_id, role').eq('user_id', user.id).eq('status', 'current').single()
if (!member || (member.role !== 'admin' && member.role !== 'board')) return { error: 'Admin access required' }
const inserts = rows.map(r => ({
space_id: member.space_id,
display_name: r.display_name,
email: r.email,
phone: r.phone,
tier: r.tier || 'basic',
role: 'member',
status: 'current',
approved: true,
joined_at: r.joined_at || new Date().toISOString(),
last_paid_at: r.last_paid_at || null,
has_card_access: r.has_card_access ?? false,
}))
// Upsert by email to avoid duplicates
const { data, error } = await supabase.from('space_members').upsert(inserts, {
onConflict: 'space_id,email',
ignoreDuplicates: false,
}).select()
if (error) return { error: error.message }
revalidatePath('/members')
return { data, count: data.length }
}
+201
View File
@@ -0,0 +1,201 @@
'use server'
import { createClient } from '@/lib/supabase/server'
import { createAdminClient } from '@/lib/supabase/admin'
import { redirect } from 'next/navigation'
function generateInviteCode() {
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789'
const prefix = 'HSL'
const year = new Date().getFullYear()
let code = ''
for (let i = 0; i < 4; i++) code += chars.charAt(Math.floor(Math.random() * chars.length))
return `${prefix}-${year}-${code}`
}
export async function createSpace(formData: {
spaceName: string
spaceSlug: string
spaceCity?: string
displayName: string
}) {
// Verify user is authenticated using regular client
const supabase = await createClient()
const { data: { user }, error: userErr } = await supabase.auth.getUser()
if (userErr || !user) return { error: 'Not authenticated' }
// Use admin client for inserts (bypasses RLS)
const admin = createAdminClient()
// Check if slug already exists
const { data: existingSpace } = await admin
.from('spaces')
.select('id')
.eq('slug', formData.spaceSlug.toLowerCase().trim())
.maybeSingle()
if (existingSpace) {
return { error: 'A space with this URL slug already exists. Please choose a different one.' }
}
// Create the space
const { data: space, error: spaceErr } = await admin
.from('spaces')
.insert({
name: formData.spaceName,
slug: formData.spaceSlug.toLowerCase().trim(),
city: formData.spaceCity || null,
invite_code: generateInviteCode(),
})
.select('id')
.single()
if (spaceErr || !space) {
if (spaceErr?.code === '23505') {
return { error: 'A space with this URL slug already exists. Please choose a different one.' }
}
return { error: spaceErr?.message ?? 'Failed to create space' }
}
// Add user as admin
const { error: memberErr } = await admin
.from('space_members')
.insert({
space_id: space.id,
user_id: user.id,
display_name: formData.displayName || user.email,
email: user.email,
role: 'admin',
tier: 'plus',
status: 'current',
approved: true,
})
if (memberErr) {
return { error: memberErr.message }
}
return { success: true }
}
export async function joinSpace(formData: {
inviteCode: string
displayName: string
}) {
// Verify user is authenticated
const supabase = await createClient()
const { data: { user }, error: userErr } = await supabase.auth.getUser()
if (userErr || !user) return { error: 'Not authenticated' }
// Use admin client for operations
const admin = createAdminClient()
const { data: space, error: lookupErr } = await admin
.from('spaces')
.select('id, require_approval')
.eq('invite_code', formData.inviteCode.trim().toUpperCase())
.single()
if (lookupErr || !space) return { error: 'Invalid invite code' }
const { error: memberErr } = await admin
.from('space_members')
.insert({
space_id: space.id,
user_id: user.id,
display_name: formData.displayName || user.email,
email: user.email,
role: 'member',
tier: 'basic',
status: space.require_approval ? 'unverified' : 'current',
approved: true,
})
if (memberErr) return { error: memberErr.message }
return { success: true }
}
export async function signIn(email: string, password: string) {
const supabase = await createClient()
const { data, error } = await supabase.auth.signInWithPassword({
email,
password,
})
if (error) {
return { error: error.message }
}
return { data }
}
export async function signUp(formData: {
email: string
password: string
fullName: string
action: 'create' | 'join'
spaceName?: string
spaceSlug?: string
city?: string
inviteCode?: string
}) {
const supabase = await createClient()
const { data: authData, error: authError } = await supabase.auth.signUp({
email: formData.email,
password: formData.password,
options: {
data: {
full_name: formData.fullName,
action: formData.action,
space_name: formData.spaceName,
space_slug: formData.spaceSlug,
city: formData.city,
invite_code: formData.inviteCode,
},
},
})
if (authError) {
return { error: authError.message }
}
return { data: authData }
}
export async function signOut() {
const supabase = await createClient()
await supabase.auth.signOut()
redirect('/login')
}
export async function getUser() {
const supabase = await createClient()
const { data: { user }, error } = await supabase.auth.getUser()
if (error || !user) {
return null
}
return user
}
export async function getCurrentMembership() {
const supabase = await createClient()
const user = await getUser()
if (!user) return null
const { data, error } = await supabase
.from('space_members')
.select('*, spaces(*)')
.eq('user_id', user.id)
.in('status', ['current', 'unverified', 'late'])
.maybeSingle()
if (error || !data) return null
return data
}
+19
View File
@@ -0,0 +1,19 @@
import { createClient } from '@supabase/supabase-js'
/**
* Admin client using the service role key.
* This bypasses RLS - use only for trusted server-side operations.
* NEVER expose this client to the browser.
*/
export function createAdminClient() {
return createClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.SUPABASE_SERVICE_ROLE_KEY!,
{
auth: {
autoRefreshToken: false,
persistSession: false,
},
}
)
}
+8
View File
@@ -0,0 +1,8 @@
import { createBrowserClient } from '@supabase/ssr'
export function createClient() {
return createBrowserClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
)
}
+69
View File
@@ -0,0 +1,69 @@
import { createServerClient } from '@supabase/ssr'
import { NextResponse, type NextRequest } from 'next/server'
export async function updateSession(request: NextRequest) {
let supabaseResponse = NextResponse.next({
request,
})
// With Fluid compute, don't put this client in a global environment
// variable. Always create a new one on each request.
const supabase = createServerClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
{
cookies: {
getAll() {
return request.cookies.getAll()
},
setAll(cookiesToSet) {
cookiesToSet.forEach(({ name, value }) =>
request.cookies.set(name, value),
)
supabaseResponse = NextResponse.next({
request,
})
cookiesToSet.forEach(({ name, value, options }) =>
supabaseResponse.cookies.set(name, value, options),
)
},
},
},
)
// Do not run code between createServerClient and
// supabase.auth.getUser(). A simple mistake could make it very hard to debug
// issues with users being randomly logged out.
// IMPORTANT: If you remove getUser() and you use server-side rendering
// with the Supabase client, your users may be randomly logged out.
const {
data: { user },
} = await supabase.auth.getUser()
if (
// if the user is not logged in and the app path, in this case, /protected, is accessed, redirect to the login page
request.nextUrl.pathname.startsWith('/protected') &&
!user
) {
// no user, potentially respond by redirecting the user to the login page
const url = request.nextUrl.clone()
url.pathname = '/login'
return NextResponse.redirect(url)
}
// IMPORTANT: You *must* return the supabaseResponse object as it is.
// If you're creating a new response object with NextResponse.next() make sure to:
// 1. Pass the request in it, like so:
// const myNewResponse = NextResponse.next({ request })
// 2. Copy over the cookies, like so:
// myNewResponse.cookies.setAll(supabaseResponse.cookies.getAll())
// 3. Change the myNewResponse object to fit your needs, but avoid changing
// the cookies!
// 4. Finally:
// return myNewResponse
// If this is not done, you may be causing the browser and server to go out
// of sync and terminate the user's session prematurely!
return supabaseResponse
}
+34
View File
@@ -0,0 +1,34 @@
import { createServerClient } from '@supabase/ssr'
import { cookies } from 'next/headers'
/**
* Especially important if using Fluid compute: Don't put this client in a
* global variable. Always create a new client within each function when using
* it.
*/
export async function createClient() {
const cookieStore = await cookies()
return createServerClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
{
cookies: {
getAll() {
return cookieStore.getAll()
},
setAll(cookiesToSet) {
try {
cookiesToSet.forEach(({ name, value, options }) =>
cookieStore.set(name, value, options),
)
} catch {
// The "setAll" method was called from a Server Component.
// This can be ignored if you have proxy refreshing
// user sessions.
}
},
},
},
)
}

Some files were not shown because too many files have changed in this diff Show More