initiall commit

This commit is contained in:
Tomas Dvorak
2026-04-10 12:03:31 +02:00
commit 7ddfb1f52b
276 changed files with 37629 additions and 0 deletions
+578
View File
@@ -0,0 +1,578 @@
# Primora Component Quick Reference
A quick reference guide for using Primora's enhanced UI components.
---
## 🚀 Quick Start
```tsx
import {
Button,
Card,
Modal,
Tooltip,
Dropdown,
Progress,
Tabs,
toast,
ToastContainer,
} from "./components";
```
---
## 📦 Components
### Button
```tsx
// Primary action
<Button variant="primary" onClick={handleClick}>
Create Project
</Button>
// With icon
<Button variant="secondary" icon={<Icons.Plus />}>
Add Item
</Button>
// Loading state
<Button variant="primary" loading={isSubmitting()}>
Saving...
</Button>
// Sizes
<Button size="sm">Small</Button>
<Button size="md">Medium</Button>
<Button size="lg">Large</Button>
// Variants
<Button variant="primary">Primary</Button>
<Button variant="secondary">Secondary</Button>
<Button variant="ghost">Ghost</Button>
<Button variant="danger">Danger</Button>
```
### Card
```tsx
// Basic card
<Card>
<CardHeader title="Project Details" />
<p>Card content goes here</p>
</Card>
// With eyebrow and description
<Card variant="elevated">
<CardHeader
eyebrow="Overview"
title="Dashboard"
description="Monitor your metrics"
/>
<CardContent>
{/* Content */}
</CardContent>
<CardFooter align="right">
<Button>Action</Button>
</CardFooter>
</Card>
// Stat card
<StatCard
label="Total Users"
value={1234}
icon={<Icons.Users />}
trend="up"
trendValue="+12%"
/>
// Interactive card
<Card variant="interactive" onClick={handleClick}>
Clickable card
</Card>
```
### Modal
```tsx
const [open, setOpen] = createSignal(false);
<Modal
open={open()}
onClose={() => setOpen(false)}
title="Confirm Action"
description="Are you sure you want to proceed?"
size="md"
>
<p>Modal content</p>
<ModalFooter align="right">
<Button variant="secondary" onClick={() => setOpen(false)}>
Cancel
</Button>
<Button variant="primary" onClick={handleConfirm}>
Confirm
</Button>
</ModalFooter>
</Modal>
```
### Tooltip
```tsx
<Tooltip content="Delete this item" placement="top">
<Button variant="ghost" icon={<Icons.Trash />} />
</Tooltip>
// With delay
<Tooltip content="Helpful hint" delay={500}>
<span>Hover me</span>
</Tooltip>
```
### Dropdown
```tsx
<Dropdown
trigger={<Button variant="secondary">Actions</Button>}
placement="bottom-end"
items={[
{
id: "edit",
label: "Edit",
icon: <Icons.Edit />,
onClick: () => handleEdit(),
},
{
id: "duplicate",
label: "Duplicate",
icon: <Icons.Copy />,
onClick: () => handleDuplicate(),
},
{ id: "divider", divider: true },
{
id: "delete",
label: "Delete",
icon: <Icons.Trash />,
danger: true,
onClick: () => handleDelete(),
},
]}
/>
```
### Progress
```tsx
// Linear progress
<Progress
value={75}
max={100}
showLabel
label="Upload Progress"
variant="success"
/>
// Circular progress
<CircularProgress
value={60}
showLabel
size={80}
variant="primary"
/>
// Spinner
<Spinner size="md" variant="primary" />
```
### Tabs
```tsx
<Tabs
variant="pills"
defaultTab="overview"
onChange={(tabId) => console.log(tabId)}
tabs={[
{
id: "overview",
label: "Overview",
icon: <Icons.Dashboard />,
content: <OverviewPanel />,
},
{
id: "settings",
label: "Settings",
badge: "3",
content: <SettingsPanel />,
},
{
id: "disabled",
label: "Disabled",
disabled: true,
content: null,
},
]}
/>
// Variants
<Tabs variant="default" tabs={...} />
<Tabs variant="pills" tabs={...} />
<Tabs variant="underline" tabs={...} />
```
### Toast
```tsx
// Add to app root
<ToastContainer />
// Use anywhere
toast.success("Operation successful!");
toast.error("Something went wrong", "Error");
toast.warning("Please review your changes");
toast.info("New update available", undefined, 10000);
// Manual control
const id = toast.show({
variant: "info",
message: "Processing...",
duration: 0, // Won't auto-dismiss
});
// Dismiss manually
toast.dismiss(id);
toast.dismissAll();
```
### Input
```tsx
// Text input
<Input
label="Project Name"
placeholder="Enter name"
value={name()}
onInput={(e) => setName(e.currentTarget.value)}
error={errors().name}
/>
// Textarea
<Textarea
label="Description"
placeholder="Enter description"
rows={4}
value={description()}
onInput={(e) => setDescription(e.currentTarget.value)}
/>
// Select
<Select
label="Status"
value={status()}
onChange={(e) => setStatus(e.currentTarget.value)}
>
<option value="active">Active</option>
<option value="inactive">Inactive</option>
</Select>
// File input
<FileInput
label="Upload File"
accept="image/*"
onChange={(e) => setFile(e.currentTarget.files?.[0])}
/>
```
### Table
```tsx
<Table
columns={[
{ key: "name", header: "Name", width: "40%" },
{ key: "email", header: "Email" },
{
key: "status",
header: "Status",
render: (value) => <StatusBadge status={value} />
},
{
key: "actions",
header: "",
align: "right",
render: (_, row) => (
<Button
variant="ghost"
size="sm"
onClick={() => handleEdit(row)}
>
Edit
</Button>
),
},
]}
data={users()}
rowKey={(row) => row.id}
onRowClick={(row) => console.log(row)}
emptyMessage="No users found"
/>
// With pagination
<DataTable
columns={columns}
data={currentPage()}
>
<Pagination
currentPage={page()}
totalPages={totalPages()}
onPageChange={setPage}
/>
</DataTable>
```
### Badge
```tsx
<Badge variant="primary">New</Badge>
<Badge variant="success">Active</Badge>
<Badge variant="warning">Pending</Badge>
<Badge variant="error">Failed</Badge>
<Badge variant="neutral">Draft</Badge>
// Status badge
<StatusBadge status="active" />
<StatusBadge status="pending" />
<StatusBadge status="completed" />
<StatusBadge status="error" />
```
### Message
```tsx
<Message variant="info" title="Information">
This is an informational message.
</Message>
<Message variant="success" icon={<Icons.Check />}>
Operation completed successfully!
</Message>
<Message
variant="error"
dismissible
onDismiss={() => setError(null)}
>
{error()}
</Message>
```
### Layout
```tsx
<Layout
sidebar={
<Sidebar
items={navItems}
activeId={activeView()}
onSelect={setActiveView}
header={<Logo />}
footer={<UserMenu />}
/>
}
header={
<Header
title="Dashboard"
subtitle="Overview"
actions={<Button>Action</Button>}
/>
}
>
<PageHeader
eyebrow="Overview"
title="Dashboard"
description="Monitor your metrics"
actions={<Button variant="primary">Create</Button>}
/>
{/* Page content */}
</Layout>
```
---
## 🎨 CSS Utilities
### Animations
```tsx
<div class="animate-fade-in">Fade in</div>
<div class="animate-slide-up">Slide up</div>
<div class="animate-scale-in">Scale in</div>
<div class="animate-bounce-in">Bounce in</div>
```
### Effects
```tsx
<Card class="card-hover-lift">Lifts on hover</Card>
<Card class="spotlight">Shine effect</Card>
<div class="glass">Frosted glass</div>
<span class="text-shimmer">Shimmer text</span>
```
### Loading States
```tsx
<div class="skeleton h-4 w-32" />
<div class="skeleton-wave h-20 w-full" />
<SkeletonCard lines={3} />
```
### Stagger Animations
```tsx
<div class="stagger-fade-in">
<div>Item 1</div>
<div>Item 2</div>
<div>Item 3</div>
</div>
```
---
## 🎯 Common Patterns
### Form with Validation
```tsx
<form onSubmit={handleSubmit} class="space-y-4">
<Input
label="Email"
type="email"
value={email()}
onInput={(e) => setEmail(e.currentTarget.value)}
error={errors().email}
/>
<Input
label="Password"
type="password"
value={password()}
onInput={(e) => setPassword(e.currentTarget.value)}
error={errors().password}
/>
<Button
type="submit"
variant="primary"
loading={submitting()}
class="w-full"
>
Sign In
</Button>
</form>
```
### Confirmation Dialog
```tsx
const [showConfirm, setShowConfirm] = createSignal(false);
<Modal
open={showConfirm()}
onClose={() => setShowConfirm(false)}
title="Confirm Deletion"
description="This action cannot be undone."
size="sm"
>
<ModalFooter align="right">
<Button
variant="secondary"
onClick={() => setShowConfirm(false)}
>
Cancel
</Button>
<Button
variant="danger"
onClick={handleDelete}
>
Delete
</Button>
</ModalFooter>
</Modal>
```
### Dashboard Grid
```tsx
<div class="dashboard-grid">
<StatCard label="Users" value={users()} />
<StatCard label="Revenue" value={`$${revenue()}`} />
<StatCard label="Growth" value="+12%" trend="up" />
</div>
```
### Action Menu
```tsx
<Dropdown
trigger={
<Button variant="ghost" size="sm">
<Icons.Menu />
</Button>
}
items={[
{ id: "view", label: "View Details", icon: <Icons.Eye /> },
{ id: "edit", label: "Edit", icon: <Icons.Edit /> },
{ id: "divider", divider: true },
{ id: "delete", label: "Delete", icon: <Icons.Trash />, danger: true },
]}
/>
```
### Loading State
```tsx
<Show
when={!loading()}
fallback={
<div class="flex items-center justify-center py-12">
<Spinner size="lg" />
</div>
}
>
{/* Content */}
</Show>
```
### Empty State
```tsx
<EmptyState
icon={<Icons.Inbox class="h-12 w-12" />}
title="No projects yet"
description="Get started by creating your first project"
action={
<Button variant="primary" onClick={handleCreate}>
Create Project
</Button>
}
/>
```
---
## 💡 Tips
### Performance
- Use `createMemo` for expensive computations
- Leverage SolidJS fine-grained reactivity
- Avoid unnecessary re-renders
- Use `Show` instead of ternary for conditional rendering
### Accessibility
- Always provide labels for inputs
- Use semantic HTML
- Test keyboard navigation
- Ensure color contrast
### Styling
- Use Tailwind utilities first
- Leverage CSS custom properties for theming
- Keep component styles scoped
- Use consistent spacing
### State Management
- Keep state close to where it's used
- Use signals for reactive state
- Lift state only when necessary
- Consider context for global state
---
## 🔗 Related Files
- `apps/frontend/src/index.css` - Global styles and design tokens
- `apps/frontend/tailwind.config.cjs` - Tailwind configuration
- `FRONTEND_ENHANCEMENTS.md` - Detailed enhancement documentation
- `project_frontend.md` - Design system specification
---
**Happy coding! 🚀**
+37
View File
@@ -0,0 +1,37 @@
FROM node:22-alpine AS builder
WORKDIR /app
# Copy root configs for workspace context
COPY package.json package-lock.json tsconfig.base.json ./
COPY apps/auth/package.json ./apps/auth/package.json
COPY apps/frontend/package.json ./apps/frontend/package.json
COPY packages/api-client/package.json ./packages/api-client/package.json
COPY packages/shared-types/package.json ./packages/shared-types/package.json
RUN npm ci
# Copy full source
COPY . .
# Generate client
RUN npm run generate:client
# Build frontend
WORKDIR /app/apps/frontend
RUN npm run build
# Stage 2: Serve with Nginx
FROM nginx:1.27-alpine
COPY --from=builder /app/apps/frontend/dist /usr/share/nginx/html
# Add simple nginx config for SPA routing
RUN echo 'server { \
listen 80; \
location / { \
root /usr/share/nginx/html; \
index index.html; \
try_files $uri $uri/ /index.html; \
} \
}' > /etc/nginx/conf.d/default.conf
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]
+292
View File
@@ -0,0 +1,292 @@
# Primora Platform - Feature Guide
## 🎯 Dashboard
### Project Dashboard
When you select a project, you'll see:
**Statistics Cards**
- Storage buckets count
- API keys count
- Team members count
- Audit log events count
**Usage Charts**
- Bandwidth usage over time
- Request count analytics
**Quick Actions**
- Create Bucket - Set up storage instantly
- Generate API Key - Authenticate your apps
- Invite Members - Add team collaborators
**Help Section**
- Documentation links
- Getting started guides
## 📁 Projects Page
### Features
- **Grid View**: Visual cards for all projects
- **Search**: Real-time filtering by name, slug, or description
- **Create Modal**: Streamlined project creation
- Auto-generates slug from name
- Optional description
- Instant validation
- **Project Cards**: Show name, slug, role, and description
- **Quick Actions**: View dashboard or manage settings
- **Settings Panel**: Edit project details (admin only)
### User Flow
1. Click "New Project" button
2. Enter project name (slug auto-generates)
3. Add optional description
4. Click "Create Project"
5. Onboarding modal appears automatically
6. Navigate to project dashboard
## 👥 Members Page
### Organization Members Tab
- **Member List**: All organization members with avatars
- **Role Management**: Inline role updates (Owner, Admin, Member)
- **Search**: Filter members by name or email
- **Remove Members**: Quick removal (except yourself)
### Project Members Tab
- **Project-Specific**: Members with project access
- **Role Options**: Admin, Developer, Viewer
- **Granular Control**: Different roles per project
### Pending Invitations
- **Visual Indicators**: Yellow highlight for pending invites
- **Invitation Details**: Email, roles, and date
- **Revoke Option**: Cancel pending invitations
### Invite Modal
- **Email Input**: Send invitations via email
- **Organization Role**: Set org-level permissions
- **Project Attachment**: Optionally add to current project
- **Project Role**: Set project-level permissions
## 💾 Storage Page
### Three-Panel Layout
**Left Panel: Buckets**
- List of all buckets
- Search functionality
- Bucket stats (object count, size)
- Visibility badges (Public/Private)
- Click to select
**Center Panel: Objects**
- Table view of files
- File details (name, size, type, date)
- Quick actions (preview, download, delete)
- Pagination for large lists
- Empty state with upload prompt
**Right Panel: Settings**
- Bucket name and slug
- Visibility toggle
- Update button
- Delete bucket (with confirmation)
### Modals
**Create Bucket**
- Name input with auto-slug
- Visibility selection
- Instant creation
**Upload File**
- File selector
- File preview before upload
- Size and type display
- Progress indication
**Object Preview**
- Image preview for images
- Text preview for code/text files
- Download prompt for other types
- Truncation warning for large files
## ⚙️ Settings Page
### API Keys Tab
- **Key List**: All API keys with status
- **Create Key**: Generate new authentication keys
- **One-Time Secret**: Secure key display (copy immediately!)
- **Key Management**: Revoke keys when needed
- **Security Warning**: Prominent security best practices
### Organization Tab
- **Organization Details**: Name and slug
- **Update Settings**: Modify organization info
- **Danger Zone**: Delete organization (owner only)
- **Warning Messages**: Clear consequences of actions
### General Tab
- **Theme Selection**: Light, Dark, System
- **Language**: Multiple language support
- **Timezone**: Set your timezone
- **Notifications**: Email, security, product updates
## 📊 Audit Page
### Statistics Dashboard
- **Total Events**: All logged activities
- **Creates**: New resource count
- **Updates**: Modified resource count
- **Deletes**: Removed resource count
### Filters
- **Search**: Find by resource, actor, or details
- **Action Filter**: Filter by action type (create, update, delete, etc.)
- **Real-time**: Instant filtering
### Audit Log Table
- **Timestamp**: When the action occurred
- **Action**: What happened (with color-coded badges)
- **Resource**: What was affected
- **Actor**: Who performed the action (with avatar)
- **Details**: Expandable JSON metadata
### Export Options
- **CSV Export**: For spreadsheet analysis
- **JSON Export**: For programmatic processing
- **Compliance**: Meet audit requirements
## 🎓 Onboarding Modal
### Step 1: Welcome
- Overview of setup process
- Visual progress indicators
- Skip option available
### Step 2: API Keys
- Explanation of API keys
- Security tips
- Link to settings
### Step 3: Integration
- Code snippet for your language
- Copy-paste ready
- Documentation link
## 🎨 Design Features
### Visual Elements
- **Modern Cards**: Clean, elevated design
- **Color-Coded Badges**: Instant status recognition
- **Smooth Animations**: Fade-ins, slide-ins, scale effects
- **Hover States**: Interactive feedback
- **Loading States**: Skeleton screens and spinners
### User Experience
- **Empty States**: Helpful messages and actions
- **Error Messages**: Clear, actionable feedback
- **Confirmation Dialogs**: Prevent accidental deletions
- **Toast Notifications**: Non-intrusive updates
- **Keyboard Navigation**: Full keyboard support
### Responsive Design
- **Mobile-First**: Works on all screen sizes
- **Touch-Friendly**: Large tap targets
- **Adaptive Layouts**: Grid to stack on mobile
- **Collapsible Panels**: Save space on small screens
## 🔐 Security Features
### API Key Management
- One-time secret display
- Secure storage recommendations
- Revocation capability
- Activity tracking
### Access Control
- Role-based permissions
- Organization-level roles
- Project-level roles
- Owner-only actions
### Audit Trail
- Complete activity log
- Actor identification
- Resource tracking
- Metadata preservation
## 🚀 Performance
### Optimizations
- **Pagination**: Load data in chunks
- **Search Debouncing**: Reduce API calls
- **Lazy Loading**: Load modals on demand
- **Efficient Rendering**: SolidJS reactivity
- **Caching**: Reduce redundant requests
### User Feedback
- **Loading Indicators**: Know when things are processing
- **Progress Bars**: Track long operations
- **Skeleton Screens**: Show structure while loading
- **Error Recovery**: Retry failed operations
## 💡 Tips & Tricks
### Keyboard Shortcuts (Future)
- `Cmd/Ctrl + K`: Command palette
- `Cmd/Ctrl + N`: New project
- `Cmd/Ctrl + ,`: Settings
- `Esc`: Close modals
### Best Practices
1. **Projects**: Use descriptive names and slugs
2. **Members**: Assign minimal required permissions
3. **Storage**: Organize with clear bucket names
4. **API Keys**: Rotate keys regularly
5. **Audit**: Review logs periodically
### Common Workflows
**Setting Up a New Project**
1. Create project
2. Complete onboarding
3. Generate API key
4. Create storage bucket
5. Invite team members
6. Start building!
**Managing Team Access**
1. Invite via email
2. Set organization role
3. Attach to projects
4. Set project roles
5. Monitor via audit logs
**Organizing Storage**
1. Create buckets by purpose
2. Set appropriate visibility
3. Upload files
4. Use consistent naming
5. Monitor usage
## 🆘 Support
### Getting Help
- **Documentation**: Comprehensive guides
- **In-App Tips**: Contextual help messages
- **Error Messages**: Actionable solutions
- **Support Team**: Contact for assistance
### Feedback
- Report bugs
- Request features
- Share suggestions
- Rate your experience
---
**Version**: 2.0.0
**Last Updated**: 2024
**Platform**: Primora
+289
View File
@@ -0,0 +1,289 @@
# Frontend Improvements Summary
## Overview
Comprehensive redesign and enhancement of the Primora platform frontend with modern UI/UX patterns, improved navigation, and dedicated page components for better maintainability.
## Major Changes
### 1. New Page Components Architecture
Created dedicated page components for better code organization and reusability:
#### **ProjectsPage** (`src/pages/ProjectsPage.tsx`)
- Modern card-based project grid layout
- Real-time search and filtering
- Inline project creation modal with auto-slug generation
- Project settings management
- Visual indicators for selected projects
- Role-based badges (admin, member, etc.)
- Quick navigation to project dashboard
#### **MembersPage** (`src/pages/MembersPage.tsx`)
- Tabbed interface for Organization and Project members
- Pending invitations section with visual indicators
- Advanced member search functionality
- Inline role management with dropdowns
- Member invitation modal with project attachment option
- Avatar generation for members
- Comprehensive member actions (remove, update role)
#### **StoragePage** (`src/pages/StoragePage.tsx`)
- Three-column layout: Buckets list, Objects table, Settings panel
- Bucket creation and management modals
- File upload with drag-and-drop support
- Object preview modal (images, text, unsupported types)
- Advanced search and filtering
- Pagination for large object lists
- Visibility badges (public/private)
- Quick actions (download, delete, preview)
#### **SettingsPage** (`src/pages/SettingsPage.tsx`)
- Tabbed interface: API Keys, Organization, General
- API key creation with secure secret display
- One-time secret viewing with copy-to-clipboard
- Organization settings management
- Danger zone for destructive actions
- General settings (theme, language, timezone)
- Notification preferences
- Security alerts and warnings
#### **AuditPage** (`src/pages/AuditPage.tsx`)
- Advanced filtering (search, action type)
- Statistics cards (total events, creates, updates, deletes)
- Color-coded action badges
- Expandable details for each log entry
- Pagination for large datasets
- Export functionality (CSV, JSON)
- Real-time refresh capability
- Visual action icons
### 2. Enhanced Dashboard
- **ProjectDashboard** component with:
- Project statistics (Storage, API Keys, Members, Audit Logs)
- Usage charts placeholders (Bandwidth, Requests)
- Quick action cards for common tasks
- Documentation links
- Modern card-based layout
### 3. Onboarding Experience
- **OnboardingModal** component with 3-step wizard:
- Step 1: Welcome and overview
- Step 2: API key creation guide
- Step 3: Code snippet for integration
- Skip functionality
- Progress indicators
- Contextual tips and warnings
### 4. Navigation Improvements
- Platform-wide navigation (not service-specific)
- Organization and Project selectors in header
- Visual separator between selectors
- Breadcrumb-style navigation
- Active state indicators
- Responsive design for mobile
### 5. Component Enhancements
#### **Tabs Component**
- Added `activeTab` prop for controlled state
- Support for external state management
- Improved accessibility
- Better visual feedback
#### **Button Component**
- Added `outline` variant
- Consistent styling across all pages
- Loading states
- Icon support
#### **Modal Component**
- Size variants (sm, md, lg, xl, full)
- Backdrop click handling
- Escape key support
- Smooth animations
### 6. UI/UX Improvements
#### Visual Design
- Consistent color scheme
- Modern card-based layouts
- Smooth transitions and animations
- Hover states for interactive elements
- Loading skeletons
- Empty states with helpful messages
#### User Experience
- Inline editing where appropriate
- Confirmation dialogs for destructive actions
- Real-time search and filtering
- Pagination for large datasets
- Toast notifications
- Error handling with user-friendly messages
#### Accessibility
- Proper ARIA labels
- Keyboard navigation support
- Focus management
- Screen reader friendly
- Color contrast compliance
### 7. Code Organization
#### File Structure
```
apps/frontend/src/
├── components/
│ ├── OnboardingModal.tsx (new)
│ ├── ProjectDashboard.tsx (new)
│ └── ... (existing components)
├── pages/
│ ├── ProjectsPage.tsx (new)
│ ├── MembersPage.tsx (new)
│ ├── StoragePage.tsx (new)
│ ├── SettingsPage.tsx (new)
│ ├── AuditPage.tsx (new)
│ └── index.ts (new)
└── App.tsx (refactored)
```
#### Benefits
- Separation of concerns
- Easier testing
- Better code reusability
- Simplified maintenance
- Clearer component hierarchy
### 8. Performance Optimizations
- Lazy loading for modals
- Efficient re-rendering with SolidJS signals
- Pagination to reduce data load
- Search debouncing (can be added)
- Virtual scrolling for large lists (infrastructure ready)
### 9. Developer Experience
- TypeScript interfaces for all props
- Consistent naming conventions
- Comprehensive prop documentation
- Reusable utility functions
- Clear component APIs
## Features Added
### Projects Management
- ✅ Grid view with search
- ✅ Modal-based creation
- ✅ Auto-slug generation
- ✅ Inline settings editing
- ✅ Role-based access control
- ✅ Quick navigation to dashboard
### Members Management
- ✅ Organization and project member tabs
- ✅ Pending invitations tracking
- ✅ Inline role updates
- ✅ Member search
- ✅ Invitation modal with project attachment
- ✅ Member removal with confirmation
### Storage Management
- ✅ Bucket list with search
- ✅ Object table with pagination
- ✅ File upload modal
- ✅ Object preview (images, text)
- ✅ Visibility management
- ✅ Bulk operations ready
### Settings Management
- ✅ API key creation with secure display
- ✅ Organization settings
- ✅ General preferences
- ✅ Notification settings
- ✅ Danger zone for destructive actions
### Audit Logging
- ✅ Advanced filtering
- ✅ Statistics dashboard
- ✅ Action categorization
- ✅ Export functionality
- ✅ Detailed log viewing
## Technical Improvements
### State Management
- Centralized state in App.tsx
- Props drilling to page components
- Signal-based reactivity
- Efficient updates
### Error Handling
- User-friendly error messages
- Network error detection
- Graceful degradation
- Retry mechanisms
### Responsive Design
- Mobile-first approach
- Breakpoint-based layouts
- Touch-friendly interactions
- Adaptive navigation
## Future Enhancements
### Potential Additions
1. Real-time updates with WebSockets
2. Advanced search with filters
3. Bulk operations for resources
4. Keyboard shortcuts
5. Dark mode support
6. Internationalization (i18n)
7. Advanced analytics dashboard
8. Custom themes
9. Drag-and-drop file uploads
10. Collaborative features
### Performance
1. Code splitting
2. Image optimization
3. Caching strategies
4. Service worker for offline support
5. Progressive Web App (PWA) features
## Migration Notes
### Breaking Changes
- None - all changes are additive
### Backward Compatibility
- Existing functionality preserved
- All API calls unchanged
- State management compatible
## Testing Recommendations
### Unit Tests
- Component rendering
- User interactions
- State updates
- Error scenarios
### Integration Tests
- Page navigation
- Form submissions
- API interactions
- Modal workflows
### E2E Tests
- Complete user flows
- Multi-step processes
- Cross-page interactions
- Error recovery
## Conclusion
This comprehensive redesign transforms the Primora platform into a modern, user-friendly application with:
- **Better UX**: Intuitive navigation and workflows
- **Improved Maintainability**: Modular component architecture
- **Enhanced Performance**: Optimized rendering and data loading
- **Professional Polish**: Consistent design and interactions
- **Scalability**: Ready for future features and growth
The platform is now production-ready with a solid foundation for continued development and enhancement.
+646
View File
@@ -0,0 +1,646 @@
# Primora Frontend Migration Guide
## Overview
This guide helps you migrate from the previous component patterns to the new enhanced component library.
---
## No Breaking Changes! 🎉
Good news: **All enhancements are backward compatible**. Your existing code will continue to work without modifications. This guide shows you how to leverage the new features.
---
## New Components Available
### 1. Replace Custom Modals with Modal Component
**Before:**
```tsx
<Show when={showDialog()}>
<div class="fixed inset-0 bg-black/50 flex items-center justify-center">
<div class="bg-surface-1 rounded-lg p-6 max-w-md">
<h2>Confirm Action</h2>
<p>Are you sure?</p>
<div class="flex gap-3 mt-4">
<button onClick={() => setShowDialog(false)}>Cancel</button>
<button onClick={handleConfirm}>Confirm</button>
</div>
</div>
</div>
</Show>
```
**After:**
```tsx
<Modal
open={showDialog()}
onClose={() => setShowDialog(false)}
title="Confirm Action"
description="Are you sure?"
size="md"
>
<ModalFooter align="right">
<Button variant="secondary" onClick={() => setShowDialog(false)}>
Cancel
</Button>
<Button variant="primary" onClick={handleConfirm}>
Confirm
</Button>
</ModalFooter>
</Modal>
```
**Benefits:**
- Automatic backdrop blur
- ESC key support
- Click outside to close
- Proper z-index management
- Smooth animations
- Accessibility built-in
---
### 2. Add Tooltips to Icon Buttons
**Before:**
```tsx
<Button variant="ghost" icon={<Icons.Trash />} />
```
**After:**
```tsx
<Tooltip content="Delete this item" placement="top">
<Button variant="ghost" icon={<Icons.Trash />} />
</Tooltip>
```
**Benefits:**
- Better UX with contextual help
- Smart positioning
- Keyboard accessible
- Configurable delay
---
### 3. Replace Custom Dropdowns with Dropdown Component
**Before:**
```tsx
<Show when={showMenu()}>
<div class="absolute bg-surface-1 rounded-lg shadow-lg">
<button onClick={handleEdit}>Edit</button>
<button onClick={handleDelete}>Delete</button>
</div>
</Show>
```
**After:**
```tsx
<Dropdown
trigger={<Button variant="secondary">Actions</Button>}
items={[
{ id: "edit", label: "Edit", icon: <Icons.Edit />, onClick: handleEdit },
{ id: "divider", divider: true },
{ id: "delete", label: "Delete", icon: <Icons.Trash />, danger: true, onClick: handleDelete },
]}
/>
```
**Benefits:**
- Smart positioning
- Click outside to close
- Keyboard navigation
- Icon support
- Danger states
- Dividers
---
### 4. Use Toast Instead of Custom Alerts
**Before:**
```tsx
<Show when={message()}>
<div class="fixed bottom-4 right-4 bg-success-muted p-4 rounded-lg">
{message()}
</div>
</Show>
```
**After:**
```tsx
// Add once to app root
<ToastContainer />
// Use anywhere
toast.success("Operation successful!");
toast.error("Something went wrong");
toast.info("New update available");
```
**Benefits:**
- Global API
- Auto-dismiss
- Stacked notifications
- Multiple variants
- Smooth animations
---
### 5. Add Loading States with Progress
**Before:**
```tsx
<Show when={loading()}>
<div class="spinner" />
</Show>
```
**After:**
```tsx
// Spinner
<Spinner size="lg" variant="primary" />
// Progress bar
<Progress value={uploadProgress()} showLabel label="Uploading..." />
// Circular progress
<CircularProgress value={60} showLabel />
```
**Benefits:**
- Multiple variants
- Size options
- Label support
- Smooth animations
---
### 6. Use Tabs for Multi-Section Views
**Before:**
```tsx
<div class="flex gap-2 border-b">
<button
class={activeTab() === "overview" ? "border-b-2 border-accent" : ""}
onClick={() => setActiveTab("overview")}
>
Overview
</button>
<button
class={activeTab() === "settings" ? "border-b-2 border-accent" : ""}
onClick={() => setActiveTab("settings")}
>
Settings
</button>
</div>
<Show when={activeTab() === "overview"}>
<OverviewPanel />
</Show>
<Show when={activeTab() === "settings"}>
<SettingsPanel />
</Show>
```
**After:**
```tsx
<Tabs
variant="underline"
defaultTab="overview"
tabs={[
{ id: "overview", label: "Overview", icon: <Icons.Dashboard />, content: <OverviewPanel /> },
{ id: "settings", label: "Settings", badge: "3", content: <SettingsPanel /> },
]}
onChange={(tabId) => console.log(tabId)}
/>
```
**Benefits:**
- Multiple variants (default, pills, underline)
- Icon and badge support
- Disabled states
- Smooth transitions
- Keyboard navigation
---
## Enhanced Components
### Button Enhancements
**New Features:**
```tsx
// Loading state
<Button variant="primary" loading={submitting()}>
Saving...
</Button>
// Icon positioning
<Button icon={<Icons.Plus />} iconPosition="left">
Create
</Button>
// Sizes
<Button size="sm">Small</Button>
<Button size="md">Medium</Button>
<Button size="lg">Large</Button>
```
---
### Card Enhancements
**New Features:**
```tsx
// Elevated variant
<Card variant="elevated">
<CardHeader eyebrow="Overview" title="Dashboard" description="Monitor metrics" />
<CardContent>...</CardContent>
<CardFooter align="right">
<Button>Action</Button>
</CardFooter>
</Card>
// Interactive card
<Card variant="interactive" onClick={handleClick}>
Clickable card
</Card>
// Stat card
<StatCard
label="Total Users"
value={1234}
icon={<Icons.Users />}
trend="up"
trendValue="+12%"
/>
// Hover effects
<Card class="card-hover-lift spotlight">
Interactive card with effects
</Card>
```
---
### Input Enhancements
**New Features:**
```tsx
// Error states
<Input
label="Email"
value={email()}
onInput={(e) => setEmail(e.currentTarget.value)}
error={errors().email}
/>
// Sizes
<Input size="sm" placeholder="Small input" />
<Input size="md" placeholder="Medium input" />
<Input size="lg" placeholder="Large input" />
```
---
## New CSS Utilities
### Animations
```tsx
// Fade in
<div class="animate-fade-in">Content</div>
// Slide animations
<div class="animate-slide-up">Slide from bottom</div>
<div class="animate-slide-down">Slide from top</div>
<div class="animate-slide-left">Slide from right</div>
<div class="animate-slide-right">Slide from left</div>
// Scale and bounce
<div class="animate-scale-in">Scale in</div>
<div class="animate-bounce-in">Bounce in</div>
// Stagger children
<div class="stagger-fade-in">
<div>Item 1</div>
<div>Item 2</div>
<div>Item 3</div>
</div>
```
---
### Visual Effects
```tsx
// Card effects
<Card class="card-hover-lift">Lifts on hover</Card>
<Card class="spotlight">Shine effect</Card>
// Glass effect
<div class="glass">Frosted glass background</div>
// Text effects
<span class="text-shimmer">Shimmer text</span>
<span class="neon-glow">Neon glow</span>
<span class="gradient-text">Gradient text</span>
// Loading states
<div class="skeleton h-4 w-32" />
<div class="skeleton-wave h-20 w-full" />
<SkeletonCard lines={3} />
```
---
## Common Migration Patterns
### 1. Confirmation Dialogs
**Before:**
```tsx
const [showConfirm, setShowConfirm] = createSignal(false);
<Show when={showConfirm()}>
<div class="fixed inset-0 bg-black/50 flex items-center justify-center">
<div class="bg-surface-1 rounded-lg p-6">
<h3>Confirm Deletion</h3>
<p>This action cannot be undone.</p>
<div class="flex gap-3 mt-4">
<button onClick={() => setShowConfirm(false)}>Cancel</button>
<button onClick={handleDelete}>Delete</button>
</div>
</div>
</div>
</Show>
```
**After:**
```tsx
const [showConfirm, setShowConfirm] = createSignal(false);
<Modal
open={showConfirm()}
onClose={() => setShowConfirm(false)}
title="Confirm Deletion"
description="This action cannot be undone."
size="sm"
>
<ModalFooter align="right">
<Button variant="secondary" onClick={() => setShowConfirm(false)}>
Cancel
</Button>
<Button variant="danger" onClick={handleDelete}>
Delete
</Button>
</ModalFooter>
</Modal>
```
---
### 2. Action Menus
**Before:**
```tsx
<button onClick={() => setShowMenu(!showMenu())}></button>
<Show when={showMenu()}>
<div class="absolute bg-surface-1 rounded-lg shadow-lg">
<button onClick={handleEdit}>Edit</button>
<button onClick={handleDelete}>Delete</button>
</div>
</Show>
```
**After:**
```tsx
<Dropdown
trigger={<Button variant="ghost" size="sm"></Button>}
items={[
{ id: "view", label: "View Details", icon: <Icons.Eye /> },
{ id: "edit", label: "Edit", icon: <Icons.Edit /> },
{ id: "divider", divider: true },
{ id: "delete", label: "Delete", icon: <Icons.Trash />, danger: true },
]}
/>
```
---
### 3. Loading States
**Before:**
```tsx
<Show when={!loading()} fallback={<div class="spinner" />}>
{/* Content */}
</Show>
```
**After:**
```tsx
<Show when={!loading()} fallback={<Spinner size="lg" />}>
{/* Content */}
</Show>
// Or with progress
<Show when={!loading()} fallback={
<div class="flex flex-col items-center gap-4">
<Spinner size="lg" />
<Progress value={progress()} showLabel />
</div>
}>
{/* Content */}
</Show>
```
---
### 4. Form Submissions
**Before:**
```tsx
<form onSubmit={handleSubmit}>
<input
type="text"
value={name()}
onInput={(e) => setName(e.currentTarget.value)}
/>
<button type="submit" disabled={submitting()}>
{submitting() ? "Saving..." : "Save"}
</button>
</form>
```
**After:**
```tsx
<form onSubmit={handleSubmit} class="space-y-4">
<Input
label="Name"
value={name()}
onInput={(e) => setName(e.currentTarget.value)}
error={errors().name}
/>
<Button type="submit" variant="primary" loading={submitting()}>
Save
</Button>
</form>
```
---
### 5. Success/Error Messages
**Before:**
```tsx
<Show when={successMessage()}>
<div class="bg-success-muted text-success p-4 rounded-lg">
{successMessage()}
</div>
</Show>
```
**After:**
```tsx
// Option 1: Message component
<Show when={successMessage()}>
<Message variant="success" dismissible onDismiss={() => setSuccessMessage("")}>
{successMessage()}
</Message>
</Show>
// Option 2: Toast (recommended)
toast.success(successMessage());
```
---
## Step-by-Step Migration
### Phase 1: Add ToastContainer
```tsx
// In your App.tsx or main component
import { ToastContainer } from "./components";
export default function App() {
return (
<>
<ToastContainer />
{/* Rest of your app */}
</>
);
}
```
### Phase 2: Replace Alerts with Toasts
Replace all custom alert/message displays with toast notifications.
### Phase 3: Add Tooltips
Add tooltips to icon-only buttons for better UX.
### Phase 4: Replace Custom Modals
Migrate custom modal implementations to the Modal component.
### Phase 5: Add Dropdowns
Replace custom dropdown menus with the Dropdown component.
### Phase 6: Enhance Forms
Add loading states, error handling, and progress indicators to forms.
### Phase 7: Add Tabs
Replace custom tab implementations with the Tabs component.
---
## Best Practices
### 1. Use Semantic Components
```tsx
// Good
<Button variant="primary" onClick={handleSubmit}>Submit</Button>
// Avoid
<button class="btn btn-primary" onClick={handleSubmit}>Submit</button>
```
### 2. Leverage Loading States
```tsx
// Good
<Button loading={submitting()}>Save</Button>
// Avoid
<Button disabled={submitting()}>
{submitting() ? "Saving..." : "Save"}
</Button>
```
### 3. Use Toast for Notifications
```tsx
// Good
toast.success("Project created!");
// Avoid
setMessage("Project created!");
setTimeout(() => setMessage(""), 3000);
```
### 4. Add Tooltips to Icons
```tsx
// Good
<Tooltip content="Delete">
<Button icon={<Icons.Trash />} />
</Tooltip>
// Avoid
<Button icon={<Icons.Trash />} />
```
### 5. Use Proper Variants
```tsx
// Good
<Button variant="danger" onClick={handleDelete}>Delete</Button>
// Avoid
<Button class="bg-error" onClick={handleDelete}>Delete</Button>
```
---
## Troubleshooting
### Modal Not Showing
Make sure the Modal is rendered and `open` prop is true:
```tsx
<Modal open={show()} onClose={() => setShow(false)}>
```
### Tooltip Not Appearing
Check that the tooltip has a trigger element:
```tsx
<Tooltip content="Help text">
<button>Hover me</button>
</Tooltip>
```
### Toast Not Working
Ensure ToastContainer is added to your app root:
```tsx
<ToastContainer />
```
### Dropdown Not Positioning Correctly
The dropdown uses Portal rendering. Make sure your app has proper z-index management.
---
## Need Help?
- **Quick Reference**: See `COMPONENT_GUIDE.md`
- **Detailed Docs**: See `FRONTEND_ENHANCEMENTS.md`
- **Design System**: See `project_frontend.md`
---
**Happy migrating! 🚀**
+37
View File
@@ -0,0 +1,37 @@
<!doctype html>
<html lang="en" class="dark">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="theme-color" content="#0d0d0f" />
<meta name="color-scheme" content="dark" />
<meta name="description" content="Primora — A modern backend platform OS for developers" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
<title>Primora — Backend Platform OS</title>
<!-- Favicon -->
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<!-- Fonts -->
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
href="https://fonts.googleapis.com/css2?family=Fira+Code:wght@400;500&family=Inter:wght@400;500;600;700&display=swap"
rel="stylesheet"
/>
<!-- Critical CSS for initial render -->
<style>
html { background: #0d0d0f; }
body { opacity: 0; animation: fadeIn 0.2s ease-out forwards; }
@keyframes fadeIn { to { opacity: 1; } }
</style>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+32
View File
@@ -0,0 +1,32 @@
{
"name": "@primora/frontend",
"version": "0.2.0",
"private": true,
"type": "module",
"scripts": {
"build": "vite build",
"dev": "vite",
"preview": "vite preview",
"test": "vitest run"
},
"dependencies": {
"@primora/api-client": "0.2.0",
"@primora/shared-types": "0.2.0",
"better-auth": "^1.5.6",
"solid-js": "^1.9.12"
},
"devDependencies": {
"@solidjs/router": "^0.15.3",
"@solidjs/testing-library": "^0.8.10",
"@tailwindcss/forms": "^0.5.10",
"@testing-library/jest-dom": "^6.6.3",
"autoprefixer": "^10.4.21",
"jsdom": "^26.0.0",
"postcss": "^8.5.6",
"tailwindcss": "^3.4.17",
"typescript": "^5.9.2",
"vite": "^8.0.3",
"vite-plugin-solid": "^2.11.8",
"vitest": "^3.2.4"
}
}
+7
View File
@@ -0,0 +1,7 @@
module.exports = {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};
+13
View File
@@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Primora - Neo-Brutalist Design Showcase</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main-showcase.tsx"></script>
</body>
</html>
File diff suppressed because it is too large Load Diff
+350
View File
@@ -0,0 +1,350 @@
import { For, createSignal } from "solid-js";
import { Button, Card, CardHeader, Badge, Progress, Tabs, StatCard } from "./components";
export function ShowcasePage() {
const [activeTab, setActiveTab] = createSignal("components");
const stats = [
{ label: "Active Users", value: "1,247", trend: "up" as const, trendValue: "+12%" },
{ label: "Storage Used", value: "847 GB", trend: "up" as const, trendValue: "+8%" },
{ label: "API Requests", value: "2.4M", trend: "up" as const, trendValue: "+45%" },
{ label: "Projects", value: "23", trend: "neutral" as const },
];
const tableData = [
{ name: "Authentication Service", status: "active", uptime: "99.9%", requests: "1.2M" },
{ name: "Storage API", status: "active", uptime: "99.8%", requests: "847K" },
{ name: "Database", status: "active", uptime: "100%", requests: "2.1M" },
{ name: "Cache Layer", status: "degraded", uptime: "98.2%", requests: "3.4M" },
];
return (
<div class="min-h-screen bg-[var(--bg-main)] text-[var(--text-primary)] p-8">
<div class="max-w-7xl mx-auto space-y-12">
{/* Hero Section */}
<div class="space-y-4 animate-fade-in">
<div class="inline-flex items-center gap-2 px-3 py-1 rounded-full bg-[var(--accent-muted)] border border-[var(--accent)] text-[var(--accent)] text-sm font-medium">
<svg class="w-4 h-4" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clip-rule="evenodd" />
</svg>
Design System v1.0
</div>
<h1 class="text-5xl font-bold tracking-tight">
Primora Design System
</h1>
<p class="text-xl text-[var(--text-secondary)] max-w-2xl">
A refined, dark-first UI system built for developers. Clean, accessible, and production-ready.
</p>
</div>
{/* Stats Grid */}
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4 animate-slide-up">
<For each={stats}>
{(stat) => (
<StatCard
label={stat.label}
value={stat.value}
trend={stat.trend}
trendValue={stat.trendValue}
/>
)}
</For>
</div>
{/* Tabs Section */}
<Card>
<Tabs
tabs={[
{ id: "components", label: "Components" },
{ id: "colors", label: "Colors" },
{ id: "typography", label: "Typography" },
]}
activeTab={activeTab()}
onChange={setActiveTab}
/>
<div class="mt-6">
{activeTab() === "components" && (
<div class="space-y-8">
{/* Buttons */}
<div>
<h3 class="text-lg font-semibold mb-4">Buttons</h3>
<div class="flex flex-wrap gap-3">
<Button variant="primary">Primary Button</Button>
<Button variant="secondary">Secondary Button</Button>
<Button variant="ghost">Ghost Button</Button>
<Button variant="danger">Danger Button</Button>
<Button variant="primary" size="sm">Small</Button>
<Button variant="primary" size="lg">Large</Button>
<Button variant="primary" loading>Loading...</Button>
</div>
</div>
{/* Badges */}
<div>
<h3 class="text-lg font-semibold mb-4">Badges</h3>
<div class="flex flex-wrap gap-3">
<Badge variant="primary">Primary</Badge>
<Badge variant="success">Success</Badge>
<Badge variant="warning">Warning</Badge>
<Badge variant="error">Error</Badge>
<Badge variant="neutral">Neutral</Badge>
</div>
</div>
{/* Progress Bars */}
<div>
<h3 class="text-lg font-semibold mb-4">Progress Indicators</h3>
<div class="space-y-4">
<Progress value={75} showLabel label="CPU Usage" />
<Progress value={60} showLabel label="Memory" />
<Progress value={90} showLabel label="Disk Space" variant="warning" />
</div>
</div>
{/* Table */}
<div>
<h3 class="text-lg font-semibold mb-4">Data Table</h3>
<div class="table-container">
<table class="table">
<thead>
<tr>
<th>Service</th>
<th>Status</th>
<th>Uptime</th>
<th>Requests</th>
</tr>
</thead>
<tbody>
<For each={tableData}>
{(row) => (
<tr>
<td class="font-medium">{row.name}</td>
<td>
<Badge variant={row.status === "active" ? "success" : "warning"}>
{row.status}
</Badge>
</td>
<td class="text-[var(--text-secondary)]">{row.uptime}</td>
<td class="font-mono text-sm">{row.requests}</td>
</tr>
)}
</For>
</tbody>
</table>
</div>
</div>
</div>
)}
{activeTab() === "colors" && (
<div class="space-y-6">
<div>
<h3 class="text-lg font-semibold mb-4">Accent Color</h3>
<div class="grid grid-cols-2 md:grid-cols-4 gap-4">
<div class="space-y-2">
<div class="h-20 rounded-lg bg-[var(--accent)] border border-[var(--border)]" />
<p class="text-sm font-mono text-[var(--text-secondary)]">#19a3d9</p>
<p class="text-xs text-[var(--text-muted)]">Primary Accent</p>
</div>
<div class="space-y-2">
<div class="h-20 rounded-lg bg-[var(--accent-hover)] border border-[var(--border)]" />
<p class="text-sm font-mono text-[var(--text-secondary)]">#22b8f0</p>
<p class="text-xs text-[var(--text-muted)]">Hover State</p>
</div>
<div class="space-y-2">
<div class="h-20 rounded-lg bg-[var(--accent-muted)] border border-[var(--border)]" />
<p class="text-sm font-mono text-[var(--text-secondary)]">rgba(25, 163, 217, 0.08)</p>
<p class="text-xs text-[var(--text-muted)]">Muted</p>
</div>
<div class="space-y-2">
<div class="h-20 rounded-lg bg-[var(--accent-subtle)] border border-[var(--border)]" />
<p class="text-sm font-mono text-[var(--text-secondary)]">rgba(25, 163, 217, 0.12)</p>
<p class="text-xs text-[var(--text-muted)]">Subtle</p>
</div>
</div>
</div>
<div>
<h3 class="text-lg font-semibold mb-4">Status Colors</h3>
<div class="grid grid-cols-2 md:grid-cols-4 gap-4">
<div class="space-y-2">
<div class="h-20 rounded-lg bg-[var(--success)] border border-[var(--border)]" />
<p class="text-sm font-mono text-[var(--text-secondary)]">#22c55e</p>
<p class="text-xs text-[var(--text-muted)]">Success</p>
</div>
<div class="space-y-2">
<div class="h-20 rounded-lg bg-[var(--warning)] border border-[var(--border)]" />
<p class="text-sm font-mono text-[var(--text-secondary)]">#f59e0b</p>
<p class="text-xs text-[var(--text-muted)]">Warning</p>
</div>
<div class="space-y-2">
<div class="h-20 rounded-lg bg-[var(--error)] border border-[var(--border)]" />
<p class="text-sm font-mono text-[var(--text-secondary)]">#ef4444</p>
<p class="text-xs text-[var(--text-muted)]">Error</p>
</div>
<div class="space-y-2">
<div class="h-20 rounded-lg bg-[var(--info)] border border-[var(--border)]" />
<p class="text-sm font-mono text-[var(--text-secondary)]">#3b82f6</p>
<p class="text-xs text-[var(--text-muted)]">Info</p>
</div>
</div>
</div>
<div>
<h3 class="text-lg font-semibold mb-4">Surface Colors</h3>
<div class="grid grid-cols-2 md:grid-cols-4 gap-4">
<div class="space-y-2">
<div class="h-20 rounded-lg bg-[var(--bg-main)] border border-[var(--border)]" />
<p class="text-sm font-mono text-[var(--text-secondary)]">#131315</p>
<p class="text-xs text-[var(--text-muted)]">Background</p>
</div>
<div class="space-y-2">
<div class="h-20 rounded-lg bg-[var(--surface-1)] border border-[var(--border)]" />
<p class="text-sm font-mono text-[var(--text-secondary)]">#1d1d21</p>
<p class="text-xs text-[var(--text-muted)]">Surface 1</p>
</div>
<div class="space-y-2">
<div class="h-20 rounded-lg bg-[var(--surface-2)] border border-[var(--border)]" />
<p class="text-sm font-mono text-[var(--text-secondary)]">#2d2d31</p>
<p class="text-xs text-[var(--text-muted)]">Surface 2</p>
</div>
<div class="space-y-2">
<div class="h-20 rounded-lg bg-[var(--surface-3)] border border-[var(--border)]" />
<p class="text-sm font-mono text-[var(--text-secondary)]">#4a4a4d</p>
<p class="text-xs text-[var(--text-muted)]">Surface 3</p>
</div>
</div>
</div>
</div>
)}
{activeTab() === "typography" && (
<div class="space-y-8">
<div>
<h3 class="text-lg font-semibold mb-4">Headings</h3>
<div class="space-y-4">
<div>
<h1 class="mb-1">Heading 1</h1>
<p class="text-sm text-[var(--text-muted)] font-mono">2rem / 32px - Bold</p>
</div>
<div>
<h2 class="mb-1">Heading 2</h2>
<p class="text-sm text-[var(--text-muted)] font-mono">1.5rem / 24px - Semibold</p>
</div>
<div>
<h3 class="mb-1">Heading 3</h3>
<p class="text-sm text-[var(--text-muted)] font-mono">1.25rem / 20px - Semibold</p>
</div>
<div>
<h4 class="mb-1">Heading 4</h4>
<p class="text-sm text-[var(--text-muted)] font-mono">1.125rem / 18px - Semibold</p>
</div>
</div>
</div>
<div>
<h3 class="text-lg font-semibold mb-4">Body Text</h3>
<div class="space-y-4">
<div>
<p class="text-[var(--text-primary)] mb-1">
Primary text color - used for main content and headings
</p>
<p class="text-sm text-[var(--text-muted)] font-mono">var(--text-primary) #ededf0</p>
</div>
<div>
<p class="text-[var(--text-secondary)] mb-1">
Secondary text color - used for supporting content
</p>
<p class="text-sm text-[var(--text-muted)] font-mono">var(--text-secondary) #bebec4</p>
</div>
<div>
<p class="text-[var(--text-muted)] mb-1">
Muted text color - used for labels and metadata
</p>
<p class="text-sm text-[var(--text-muted)] font-mono">var(--text-muted) #5b5b5f</p>
</div>
</div>
</div>
<div>
<h3 class="text-lg font-semibold mb-4">Code & Monospace</h3>
<div class="space-y-4">
<div>
<p class="mb-2">Inline code: <code>const value = "example";</code></p>
</div>
<div class="bg-[var(--surface-1)] border border-[var(--border)] rounded-lg p-4">
<pre class="font-mono text-sm text-[var(--text-secondary)]">
{`function greet(name: string) {
return \`Hello, \${name}!\`;
}
const message = greet("Primora");
console.log(message);`}
</pre>
</div>
</div>
</div>
</div>
)}
</div>
</Card>
{/* Design Principles */}
<div class="grid grid-cols-1 md:grid-cols-3 gap-6">
<Card>
<div class="space-y-3">
<div class="w-12 h-12 rounded-lg bg-[var(--accent-muted)] flex items-center justify-center">
<svg class="w-6 h-6 text-[var(--accent)]" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z" />
</svg>
</div>
<h3 class="text-lg font-semibold">Fast & Responsive</h3>
<p class="text-sm text-[var(--text-secondary)]">
Built with performance in mind. Smooth transitions and instant feedback.
</p>
</div>
</Card>
<Card>
<div class="space-y-3">
<div class="w-12 h-12 rounded-lg bg-[var(--success-muted)] flex items-center justify-center">
<svg class="w-6 h-6 text-[var(--success)]" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
</div>
<h3 class="text-lg font-semibold">Accessible</h3>
<p class="text-sm text-[var(--text-secondary)]">
WCAG AA compliant with full keyboard navigation and screen reader support.
</p>
</div>
</Card>
<Card>
<div class="space-y-3">
<div class="w-12 h-12 rounded-lg bg-[var(--warning-muted)] flex items-center justify-center">
<svg class="w-6 h-6 text-[var(--warning)]" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 21a4 4 0 01-4-4V5a2 2 0 012-2h4a2 2 0 012 2v12a4 4 0 01-4 4zm0 0h12a2 2 0 002-2v-4a2 2 0 00-2-2h-2.343M11 7.343l1.657-1.657a2 2 0 012.828 0l2.829 2.829a2 2 0 010 2.828l-8.486 8.485M7 17h.01" />
</svg>
</div>
<h3 class="text-lg font-semibold">Customizable</h3>
<p class="text-sm text-[var(--text-secondary)]">
CSS variables make it easy to adapt the design system to your brand.
</p>
</div>
</Card>
</div>
{/* Footer */}
<div class="text-center py-8 border-t border-[var(--border)]">
<p class="text-[var(--text-secondary)]">
Built with SolidJS, TypeScript, and Tailwind CSS
</p>
<p class="text-sm text-[var(--text-muted)] mt-2">
Primora Design System v1.0 - Dark-first, refined, developer-focused
</p>
</div>
</div>
</div>
);
}
+45
View File
@@ -0,0 +1,45 @@
import { type JSX, splitProps } from "solid-js";
type BadgeVariant = "primary" | "success" | "warning" | "error" | "neutral";
interface BadgeProps extends JSX.HTMLAttributes<HTMLSpanElement> {
variant?: BadgeVariant;
}
const variantClasses: Record<BadgeVariant, string> = {
primary: "badge-primary",
success: "badge-success",
warning: "badge-warning",
error: "badge-error",
neutral: "badge-neutral",
};
export function Badge(props: BadgeProps) {
const [local, rest] = splitProps(props, ["variant", "children", "class"]);
const variant = () => local.variant ?? "neutral";
return (
<span class={`${variantClasses[variant()]} ${local.class ?? ""}`} {...rest}>
{local.children}
</span>
);
}
interface StatusBadgeProps {
status: "pending" | "active" | "completed" | "error" | "expired";
}
const statusMap: Record<StatusBadgeProps["status"], { variant: BadgeVariant; label: string }> = {
pending: { variant: "warning", label: "Pending" },
active: { variant: "primary", label: "Active" },
completed: { variant: "success", label: "Completed" },
error: { variant: "error", label: "Error" },
expired: { variant: "neutral", label: "Expired" },
};
export function StatusBadge(props: StatusBadgeProps) {
const config = () => statusMap[props.status];
return <Badge variant={config().variant}>{config().label}</Badge>;
}
+65
View File
@@ -0,0 +1,65 @@
import { type JSX, Show, splitProps } from "solid-js";
type ButtonVariant = "primary" | "secondary" | "ghost" | "danger" | "outline";
type ButtonSize = "sm" | "md" | "lg";
interface ButtonProps extends JSX.ButtonHTMLAttributes<HTMLButtonElement> {
variant?: ButtonVariant;
size?: ButtonSize;
loading?: boolean;
icon?: JSX.Element;
iconPosition?: "left" | "right";
}
const variantClasses: Record<ButtonVariant, string> = {
primary: "btn-primary",
secondary: "btn-secondary",
ghost: "btn-ghost",
danger: "btn-danger",
outline: "btn-secondary border border-gray-300",
};
const sizeClasses: Record<ButtonSize, string> = {
sm: "btn-sm",
md: "",
lg: "btn-lg",
};
export function Button(props: ButtonProps) {
const [local, rest] = splitProps(props, [
"variant",
"size",
"loading",
"icon",
"iconPosition",
"children",
"class",
"disabled",
]);
const variant = () => local.variant ?? "secondary";
const size = () => local.size ?? "md";
const iconPosition = () => local.iconPosition ?? "left";
return (
<button
class={`btn ${variantClasses[variant()]} ${sizeClasses[size()]} ${local.class ?? ""}`}
disabled={local.disabled ?? local.loading}
aria-busy={local.loading}
{...rest}
>
<Show when={local.loading}>
<span class="spinner" aria-hidden="true" />
</Show>
<Show when={local.icon && !local.loading && iconPosition() === "left"}>
<span class="shrink-0 flex items-center">{local.icon}</span>
</Show>
<Show when={local.children}>
<span class={local.loading ? "opacity-0" : ""}>{local.children}</span>
</Show>
<Show when={local.icon && !local.loading && iconPosition() === "right"}>
<span class="shrink-0 flex items-center">{local.icon}</span>
</Show>
</button>
);
}
+156
View File
@@ -0,0 +1,156 @@
import { type JSX, Show, splitProps } from "solid-js";
interface CardProps extends JSX.HTMLAttributes<HTMLDivElement> {
variant?: "default" | "elevated" | "interactive";
padding?: "none" | "sm" | "md" | "lg";
hoverable?: boolean;
}
const variantClasses = {
default: "card",
elevated: "card-elevated",
interactive: "card-interactive",
};
const paddingClasses = {
none: "!p-0",
sm: "!p-3",
md: "",
lg: "!p-6",
};
export function Card(props: CardProps) {
const [local, rest] = splitProps(props, ["variant", "padding", "hoverable", "children", "class"]);
const variant = () => local.variant ?? "default";
const padding = () => local.padding ?? "md";
return (
<div
class={`${variantClasses[variant()]} ${paddingClasses[padding()]} ${local.hoverable ? "card-interactive" : ""} ${local.class ?? ""}`}
{...rest}
>
{local.children}
</div>
);
}
interface CardHeaderProps extends JSX.HTMLAttributes<HTMLDivElement> {
eyebrow?: string;
title: string;
description?: string;
}
export function CardHeader(props: CardHeaderProps) {
const [local, rest] = splitProps(props, ["eyebrow", "title", "description", "class"]);
return (
<div class={`card-header ${local.class ?? ""}`} {...rest}>
<Show when={local.eyebrow}>
<p class="text-xs font-semibold text-accent uppercase tracking-wider mb-1">{local.eyebrow}</p>
</Show>
<h3 class="card-header-title">{local.title}</h3>
<Show when={local.description}>
<p class="card-header-description">{local.description}</p>
</Show>
</div>
);
}
interface CardContentProps extends JSX.HTMLAttributes<HTMLDivElement> {
/** Apply subtle background tint */
tint?: boolean;
}
export function CardContent(props: CardContentProps) {
const [local, rest] = splitProps(props, ["tint", "children", "class"]);
return (
<div
class={`space-y-4 ${local.tint ? "bg-surface-2/30 -mx-4 -mb-4 px-4 pb-4 pt-4 rounded-b-lg" : ""} ${local.class ?? ""}`}
{...rest}
>
{local.children}
</div>
);
}
interface CardFooterProps extends JSX.HTMLAttributes<HTMLDivElement> {
/** Align actions to the right */
align?: "left" | "right" | "between";
}
export function CardFooter(props: CardFooterProps) {
const [local, rest] = splitProps(props, ["align", "children", "class"]);
const alignClass = () => {
switch (local.align) {
case "right":
return "justify-end";
case "between":
return "justify-between";
default:
return "justify-start";
}
};
return (
<div
class={`flex items-center gap-3 pt-4 mt-4 border-t border-border ${alignClass()} ${local.class ?? ""}`}
{...rest}
>
{local.children}
</div>
);
}
interface StatCardProps {
label: string;
value: string | number;
icon?: JSX.Element;
trend?: "up" | "down" | "neutral";
trendValue?: string;
description?: string;
}
export function StatCard(props: StatCardProps) {
return (
<div class="stat-card group">
<div class="flex items-start justify-between">
<div class="flex-1">
<p class="stat-label">{props.label}</p>
<p class="stat-value">{props.value}</p>
<Show when={props.description}>
<p class="text-xs text-text-muted mt-1.5 leading-relaxed">{props.description}</p>
</Show>
</div>
<Show when={props.icon}>
<div class="text-text-muted opacity-40 group-hover:opacity-60 transition-opacity">{props.icon}</div>
</Show>
</div>
<Show when={props.trend && props.trendValue}>
<div
class={`mt-3 text-xs flex items-center gap-1.5 font-medium ${
props.trend === "up"
? "text-success"
: props.trend === "down"
? "text-error"
: "text-text-muted"
}`}
>
<Show when={props.trend === "up"}>
<svg class="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 15l7-7 7 7" />
</svg>
</Show>
<Show when={props.trend === "down"}>
<svg class="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" />
</svg>
</Show>
{props.trendValue}
</div>
</Show>
</div>
);
}
@@ -0,0 +1,251 @@
import { type JSX, For, Show, createSignal, createEffect, onCleanup } from "solid-js";
import { Portal } from "solid-js/web";
interface Command {
id: string;
label: string;
description?: string;
icon?: JSX.Element;
keywords?: string[];
shortcut?: string;
onExecute: () => void;
category?: string;
}
interface CommandPaletteProps {
commands: Command[];
placeholder?: string;
shortcut?: string;
}
export function CommandPalette(props: CommandPaletteProps) {
const [open, setOpen] = createSignal(false);
const [search, setSearch] = createSignal("");
const [selectedIndex, setSelectedIndex] = createSignal(0);
let inputRef: HTMLInputElement | undefined;
const shortcut = () => props.shortcut ?? "k";
// Filter commands based on search
const filteredCommands = () => {
const query = search().toLowerCase();
if (!query) return props.commands;
return props.commands.filter((cmd) => {
const searchText = [
cmd.label,
cmd.description,
...(cmd.keywords || []),
].join(" ").toLowerCase();
return searchText.includes(query);
});
};
// Group commands by category
const groupedCommands = () => {
const commands = filteredCommands();
const groups = new Map<string, Command[]>();
commands.forEach((cmd) => {
const category = cmd.category || "Commands";
if (!groups.has(category)) {
groups.set(category, []);
}
groups.get(category)!.push(cmd);
});
return Array.from(groups.entries());
};
// Keyboard shortcuts
createEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
// Open with Cmd/Ctrl + K
if ((e.metaKey || e.ctrlKey) && e.key === shortcut()) {
e.preventDefault();
setOpen(true);
return;
}
if (!open()) return;
// Close with Escape
if (e.key === "Escape") {
e.preventDefault();
setOpen(false);
setSearch("");
setSelectedIndex(0);
return;
}
// Navigate with arrows
if (e.key === "ArrowDown") {
e.preventDefault();
setSelectedIndex((i) => Math.min(i + 1, filteredCommands().length - 1));
} else if (e.key === "ArrowUp") {
e.preventDefault();
setSelectedIndex((i) => Math.max(i - 1, 0));
}
// Execute with Enter
if (e.key === "Enter") {
e.preventDefault();
const commands = filteredCommands();
if (commands[selectedIndex()]) {
executeCommand(commands[selectedIndex()]);
}
}
};
document.addEventListener("keydown", handleKeyDown);
onCleanup(() => document.removeEventListener("keydown", handleKeyDown));
});
// Focus input when opened
createEffect(() => {
if (open() && inputRef) {
requestAnimationFrame(() => inputRef?.focus());
}
});
// Reset selection when search changes
createEffect(() => {
search();
setSelectedIndex(0);
});
const executeCommand = (command: Command) => {
command.onExecute();
setOpen(false);
setSearch("");
setSelectedIndex(0);
};
return (
<>
{/* Trigger button */}
<button
class="flex items-center gap-2 px-3 py-1.5 text-sm text-text-secondary bg-surface-1 border border-border rounded-lg hover:border-border-hover transition-colors"
onClick={() => setOpen(true)}
>
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
</svg>
<span>{props.placeholder ?? "Search..."}</span>
<kbd class="ml-auto px-1.5 py-0.5 text-xs bg-surface-2 border border-border rounded">
{shortcut().toUpperCase()}
</kbd>
</button>
{/* Command palette modal */}
<Show when={open()}>
<Portal>
<div
class="fixed inset-0 z-50 flex items-start justify-center pt-[20vh] animate-fade-in"
onClick={() => setOpen(false)}
>
{/* Backdrop */}
<div class="absolute inset-0 bg-black/60 backdrop-blur-sm" />
{/* Palette */}
<div
class="relative w-full max-w-2xl bg-surface-1 border border-border-strong rounded-xl shadow-elevated animate-scale-in"
onClick={(e) => e.stopPropagation()}
>
{/* Search input */}
<div class="flex items-center gap-3 px-4 py-3 border-b border-border">
<svg class="h-5 w-5 text-text-muted" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
</svg>
<input
ref={inputRef}
type="text"
class="flex-1 bg-transparent text-text-primary placeholder-text-muted outline-none"
placeholder={props.placeholder ?? "Type a command or search..."}
value={search()}
onInput={(e) => setSearch(e.currentTarget.value)}
/>
<kbd class="px-2 py-1 text-xs text-text-muted bg-surface-2 border border-border rounded">
ESC
</kbd>
</div>
{/* Commands list */}
<div class="max-h-[400px] overflow-y-auto py-2">
<Show
when={filteredCommands().length > 0}
fallback={
<div class="px-4 py-8 text-center text-text-muted">
<p>No commands found</p>
</div>
}
>
<For each={groupedCommands()}>
{([category, commands]) => (
<div class="mb-2">
<div class="px-4 py-1.5 text-xs font-semibold text-text-muted uppercase tracking-wider">
{category}
</div>
<For each={commands}>
{(command, index) => {
const globalIndex = filteredCommands().indexOf(command);
const isSelected = () => selectedIndex() === globalIndex;
return (
<button
class={`w-full flex items-center gap-3 px-4 py-2.5 text-left transition-colors ${
isSelected()
? "bg-accent-subtle text-accent"
: "text-text-primary hover:bg-surface-2"
}`}
onClick={() => executeCommand(command)}
onMouseEnter={() => setSelectedIndex(globalIndex)}
>
<Show when={command.icon}>
<span class="flex-shrink-0">{command.icon}</span>
</Show>
<div class="flex-1 min-w-0">
<div class="font-medium">{command.label}</div>
<Show when={command.description}>
<div class="text-xs text-text-muted truncate">
{command.description}
</div>
</Show>
</div>
<Show when={command.shortcut}>
<kbd class="px-2 py-1 text-xs bg-surface-2 border border-border rounded">
{command.shortcut}
</kbd>
</Show>
</button>
);
}}
</For>
</div>
)}
</For>
</Show>
</div>
{/* Footer */}
<div class="flex items-center justify-between px-4 py-2 border-t border-border text-xs text-text-muted">
<div class="flex items-center gap-4">
<span class="flex items-center gap-1">
<kbd class="px-1.5 py-0.5 bg-surface-2 border border-border rounded"></kbd>
<kbd class="px-1.5 py-0.5 bg-surface-2 border border-border rounded"></kbd>
to navigate
</span>
<span class="flex items-center gap-1">
<kbd class="px-1.5 py-0.5 bg-surface-2 border border-border rounded"></kbd>
to select
</span>
</div>
<span>{filteredCommands().length} commands</span>
</div>
</div>
</div>
</Portal>
</Show>
</>
);
}
@@ -0,0 +1,198 @@
import { Show, For, createSignal, createEffect, onCleanup } from "solid-js";
import { Portal } from "solid-js/web";
interface Command {
id: string;
label: string;
description?: string;
icon?: any;
keywords?: string[];
action: () => void;
category?: string;
}
interface CommandPaletteEnhancedProps {
commands: Command[];
isOpen: boolean;
onClose: () => void;
}
export function CommandPaletteEnhanced(props: CommandPaletteEnhancedProps) {
const [search, setSearch] = createSignal("");
const [selectedIndex, setSelectedIndex] = createSignal(0);
const filteredCommands = () => {
const query = search().toLowerCase();
if (!query) return props.commands;
return props.commands.filter(cmd => {
const searchText = `${cmd.label} ${cmd.description || ""} ${cmd.keywords?.join(" ") || ""}`.toLowerCase();
return searchText.includes(query);
});
};
const groupedCommands = () => {
const commands = filteredCommands();
const groups: Record<string, Command[]> = {};
commands.forEach(cmd => {
const category = cmd.category || "General";
if (!groups[category]) {
groups[category] = [];
}
groups[category].push(cmd);
});
return groups;
};
const handleKeyDown = (e: KeyboardEvent) => {
if (!props.isOpen) return;
const commands = filteredCommands();
switch (e.key) {
case "ArrowDown":
e.preventDefault();
setSelectedIndex(i => Math.min(i + 1, commands.length - 1));
break;
case "ArrowUp":
e.preventDefault();
setSelectedIndex(i => Math.max(i - 1, 0));
break;
case "Enter":
e.preventDefault();
if (commands[selectedIndex()]) {
commands[selectedIndex()].action();
props.onClose();
}
break;
case "Escape":
e.preventDefault();
props.onClose();
break;
}
};
createEffect(() => {
if (props.isOpen) {
document.addEventListener("keydown", handleKeyDown);
setSearch("");
setSelectedIndex(0);
}
onCleanup(() => {
document.removeEventListener("keydown", handleKeyDown);
});
});
return (
<Show when={props.isOpen}>
<Portal>
<div class="fixed inset-0 z-50 flex items-start justify-center pt-20 px-4 animate-fade-in">
{/* Backdrop */}
<div
class="absolute inset-0 bg-black/60 backdrop-blur-sm"
onClick={props.onClose}
/>
{/* Command Palette */}
<div class="relative w-full max-w-2xl bg-white rounded-xl shadow-2xl animate-scale-in">
{/* Search Input */}
<div class="p-4 border-b border-gray-200">
<div class="flex items-center gap-3">
<svg class="w-5 h-5 text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
</svg>
<input
type="text"
placeholder="Search commands..."
value={search()}
onInput={(e) => setSearch(e.currentTarget.value)}
class="flex-1 bg-transparent border-none outline-none text-lg"
autofocus
/>
<kbd class="px-2 py-1 text-xs bg-gray-100 rounded border border-gray-300">ESC</kbd>
</div>
</div>
{/* Commands List */}
<div class="max-h-96 overflow-y-auto">
<Show
when={Object.keys(groupedCommands()).length > 0}
fallback={
<div class="p-8 text-center text-gray-500">
<svg class="w-12 h-12 mx-auto mb-2 opacity-50" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9.172 16.172a4 4 0 015.656 0M9 10h.01M15 10h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<p>No commands found</p>
</div>
}
>
<For each={Object.entries(groupedCommands())}>
{([category, commands]) => (
<div>
<div class="px-4 py-2 text-xs font-semibold text-gray-500 uppercase bg-gray-50">
{category}
</div>
<For each={commands}>
{(command, index) => {
const globalIndex = filteredCommands().indexOf(command);
return (
<button
class={`w-full flex items-center gap-3 px-4 py-3 text-left transition-colors ${
selectedIndex() === globalIndex
? "bg-blue-50 border-l-2 border-blue-500"
: "hover:bg-gray-50"
}`}
onClick={() => {
command.action();
props.onClose();
}}
onMouseEnter={() => setSelectedIndex(globalIndex)}
>
<Show when={command.icon}>
<div class="flex-shrink-0 w-8 h-8 flex items-center justify-center bg-gray-100 rounded-lg">
{command.icon}
</div>
</Show>
<div class="flex-1 min-w-0">
<div class="font-medium text-gray-900">{command.label}</div>
<Show when={command.description}>
<div class="text-sm text-gray-600 truncate">{command.description}</div>
</Show>
</div>
<Show when={selectedIndex() === globalIndex}>
<kbd class="px-2 py-1 text-xs bg-gray-100 rounded border border-gray-300"></kbd>
</Show>
</button>
);
}}
</For>
</div>
)}
</For>
</Show>
</div>
{/* Footer */}
<div class="p-3 border-t border-gray-200 bg-gray-50 flex items-center justify-between text-xs text-gray-600">
<div class="flex items-center gap-4">
<span class="flex items-center gap-1">
<kbd class="px-1.5 py-0.5 bg-white rounded border border-gray-300"></kbd>
<kbd class="px-1.5 py-0.5 bg-white rounded border border-gray-300"></kbd>
Navigate
</span>
<span class="flex items-center gap-1">
<kbd class="px-1.5 py-0.5 bg-white rounded border border-gray-300"></kbd>
Select
</span>
</div>
<span>{filteredCommands().length} commands</span>
</div>
</div>
</div>
</Portal>
</Show>
);
}
+296
View File
@@ -0,0 +1,296 @@
import { Show, For, createSignal, createMemo } from "solid-js";
import { Button, Input, Badge } from "./index";
export interface Column<T> {
key: keyof T | string;
label: string;
sortable?: boolean;
filterable?: boolean;
render?: (value: any, row: T) => any;
width?: string;
align?: "left" | "center" | "right";
}
export interface DataTableProps<T> {
data: T[];
columns: Column<T>[];
keyField: keyof T;
onRowClick?: (row: T) => void;
loading?: boolean;
emptyMessage?: string;
pageSize?: number;
searchable?: boolean;
exportable?: boolean;
onExport?: (data: T[]) => void;
}
export function DataTable<T extends Record<string, any>>(props: DataTableProps<T>) {
const [sortKey, setSortKey] = createSignal<keyof T | string | null>(null);
const [sortDirection, setSortDirection] = createSignal<"asc" | "desc">("asc");
const [searchQuery, setSearchQuery] = createSignal("");
const [currentPage, setCurrentPage] = createSignal(1);
const [filters, setFilters] = createSignal<Record<string, string>>({});
const pageSize = () => props.pageSize || 10;
// Filter data
const filteredData = createMemo(() => {
let result = [...props.data];
// Apply search
if (searchQuery().trim()) {
const query = searchQuery().toLowerCase();
result = result.filter(row => {
return props.columns.some(col => {
const value = row[col.key as keyof T];
return String(value).toLowerCase().includes(query);
});
});
}
// Apply column filters
const activeFilters = filters();
Object.entries(activeFilters).forEach(([key, value]) => {
if (value.trim()) {
result = result.filter(row => {
const cellValue = row[key as keyof T];
return String(cellValue).toLowerCase().includes(value.toLowerCase());
});
}
});
return result;
});
// Sort data
const sortedData = createMemo(() => {
const key = sortKey();
if (!key) return filteredData();
return [...filteredData()].sort((a, b) => {
const aVal = a[key as keyof T];
const bVal = b[key as keyof T];
let comparison = 0;
if (aVal < bVal) comparison = -1;
if (aVal > bVal) comparison = 1;
return sortDirection() === "asc" ? comparison : -comparison;
});
});
// Paginate data
const paginatedData = createMemo(() => {
const start = (currentPage() - 1) * pageSize();
const end = start + pageSize();
return sortedData().slice(start, end);
});
const totalPages = createMemo(() => Math.ceil(sortedData().length / pageSize()));
const handleSort = (key: keyof T | string) => {
if (sortKey() === key) {
setSortDirection(d => d === "asc" ? "desc" : "asc");
} else {
setSortKey(key);
setSortDirection("asc");
}
};
const handleFilterChange = (key: string, value: string) => {
setFilters(prev => ({ ...prev, [key]: value }));
setCurrentPage(1);
};
const getSortIcon = (key: keyof T | string) => {
if (sortKey() !== key) {
return (
<svg class="w-4 h-4 opacity-30" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 16V4m0 0L3 8m4-4l4 4m6 0v12m0 0l4-4m-4 4l-4-4" />
</svg>
);
}
return sortDirection() === "asc" ? (
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 15l7-7 7 7" />
</svg>
) : (
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" />
</svg>
);
};
return (
<div class="space-y-4">
{/* Toolbar */}
<div class="flex items-center justify-between gap-4">
<Show when={props.searchable}>
<Input
placeholder="Search..."
value={searchQuery()}
onInput={(e) => {
setSearchQuery(e.currentTarget.value);
setCurrentPage(1);
}}
class="max-w-sm"
/>
</Show>
<div class="flex items-center gap-2">
<Badge variant="secondary">
{sortedData().length} {sortedData().length === 1 ? "row" : "rows"}
</Badge>
<Show when={props.exportable && props.onExport}>
<Button
variant="outline"
size="sm"
onClick={() => props.onExport?.(sortedData())}
>
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" />
</svg>
Export
</Button>
</Show>
</div>
</div>
{/* Table */}
<div class="border border-gray-200 rounded-lg overflow-hidden">
<div class="overflow-x-auto">
<table class="w-full">
<thead class="bg-gray-50 border-b border-gray-200">
<tr>
<For each={props.columns}>
{(column) => (
<th
class={`px-4 py-3 text-${column.align || "left"} text-xs font-semibold text-gray-700 uppercase tracking-wider ${
column.sortable ? "cursor-pointer hover:bg-gray-100" : ""
}`}
style={column.width ? { width: column.width } : {}}
onClick={() => column.sortable && handleSort(column.key)}
>
<div class="flex items-center gap-2">
<span>{column.label}</span>
<Show when={column.sortable}>
{getSortIcon(column.key)}
</Show>
</div>
</th>
)}
</For>
</tr>
{/* Filter row */}
<Show when={props.columns.some(col => col.filterable)}>
<tr class="bg-gray-50">
<For each={props.columns}>
{(column) => (
<th class="px-4 py-2">
<Show when={column.filterable}>
<Input
placeholder={`Filter ${column.label}...`}
value={filters()[column.key as string] || ""}
onInput={(e) => handleFilterChange(column.key as string, e.currentTarget.value)}
class="text-sm"
size="sm"
/>
</Show>
</th>
)}
</For>
</tr>
</Show>
</thead>
<tbody class="divide-y divide-gray-200">
<Show
when={!props.loading && paginatedData().length > 0}
fallback={
<tr>
<td colspan={props.columns.length} class="px-4 py-12 text-center text-gray-500">
<Show when={props.loading} fallback={props.emptyMessage || "No data available"}>
<div class="flex items-center justify-center gap-2">
<div class="animate-spin rounded-full h-5 w-5 border-b-2 border-blue-600" />
<span>Loading...</span>
</div>
</Show>
</td>
</tr>
}
>
<For each={paginatedData()}>
{(row) => (
<tr
class={`hover:bg-gray-50 transition-colors ${props.onRowClick ? "cursor-pointer" : ""}`}
onClick={() => props.onRowClick?.(row)}
>
<For each={props.columns}>
{(column) => (
<td class={`px-4 py-3 text-${column.align || "left"} text-sm text-gray-900`}>
{column.render
? column.render(row[column.key as keyof T], row)
: String(row[column.key as keyof T] ?? "")}
</td>
)}
</For>
</tr>
)}
</For>
</Show>
</tbody>
</table>
</div>
{/* Pagination */}
<Show when={totalPages() > 1}>
<div class="px-4 py-3 bg-gray-50 border-t border-gray-200 flex items-center justify-between">
<div class="text-sm text-gray-700">
Showing {(currentPage() - 1) * pageSize() + 1} to{" "}
{Math.min(currentPage() * pageSize(), sortedData().length)} of {sortedData().length}
</div>
<div class="flex items-center gap-2">
<Button
variant="outline"
size="sm"
disabled={currentPage() === 1}
onClick={() => setCurrentPage(p => p - 1)}
>
Previous
</Button>
<div class="flex items-center gap-1">
<For each={Array.from({ length: Math.min(5, totalPages()) }, (_, i) => {
const page = i + 1;
if (totalPages() <= 5) return page;
if (currentPage() <= 3) return page;
if (currentPage() >= totalPages() - 2) return totalPages() - 4 + i;
return currentPage() - 2 + i;
})}>
{(page) => (
<button
class={`px-3 py-1 text-sm rounded ${
currentPage() === page
? "bg-blue-600 text-white"
: "text-gray-700 hover:bg-gray-100"
}`}
onClick={() => setCurrentPage(page)}
>
{page}
</button>
)}
</For>
</div>
<Button
variant="outline"
size="sm"
disabled={currentPage() === totalPages()}
onClick={() => setCurrentPage(p => p + 1)}
>
Next
</Button>
</div>
</div>
</Show>
</div>
</div>
);
}
+157
View File
@@ -0,0 +1,157 @@
import { type JSX, Show, For, createSignal, createEffect, onCleanup, splitProps } from "solid-js";
import { Portal } from "solid-js/web";
interface DropdownItem {
id: string;
label: string;
icon?: JSX.Element;
disabled?: boolean;
danger?: boolean;
divider?: boolean;
onClick?: () => void;
}
interface DropdownProps {
items: DropdownItem[];
trigger: JSX.Element;
placement?: "bottom-start" | "bottom-end" | "top-start" | "top-end";
closeOnSelect?: boolean;
}
export function Dropdown(props: DropdownProps) {
const [local] = splitProps(props, ["items", "trigger", "placement", "closeOnSelect"]);
const [open, setOpen] = createSignal(false);
const [position, setPosition] = createSignal({ x: 0, y: 0 });
let triggerRef: HTMLDivElement | undefined;
let menuRef: HTMLDivElement | undefined;
const placement = () => local.placement ?? "bottom-start";
const closeOnSelect = () => local.closeOnSelect ?? true;
const calculatePosition = () => {
if (!triggerRef || !menuRef) return;
const triggerRect = triggerRef.getBoundingClientRect();
const menuRect = menuRef.getBoundingClientRect();
const gap = 4;
let x = 0;
let y = 0;
switch (placement()) {
case "bottom-start":
x = triggerRect.left;
y = triggerRect.bottom + gap;
break;
case "bottom-end":
x = triggerRect.right - menuRect.width;
y = triggerRect.bottom + gap;
break;
case "top-start":
x = triggerRect.left;
y = triggerRect.top - menuRect.height - gap;
break;
case "top-end":
x = triggerRect.right - menuRect.width;
y = triggerRect.top - menuRect.height - gap;
break;
}
// Keep menu within viewport
x = Math.max(8, Math.min(x, window.innerWidth - menuRect.width - 8));
y = Math.max(8, Math.min(y, window.innerHeight - menuRect.height - 8));
setPosition({ x, y });
};
createEffect(() => {
if (open()) {
requestAnimationFrame(calculatePosition);
const handleClickOutside = (e: MouseEvent) => {
if (
triggerRef &&
menuRef &&
!triggerRef.contains(e.target as Node) &&
!menuRef.contains(e.target as Node)
) {
setOpen(false);
}
};
const handleEscape = (e: KeyboardEvent) => {
if (e.key === "Escape") {
setOpen(false);
}
};
document.addEventListener("mousedown", handleClickOutside);
document.addEventListener("keydown", handleEscape);
onCleanup(() => {
document.removeEventListener("mousedown", handleClickOutside);
document.removeEventListener("keydown", handleEscape);
});
}
});
const handleItemClick = (item: DropdownItem) => {
if (item.disabled) return;
item.onClick?.();
if (closeOnSelect()) {
setOpen(false);
}
};
return (
<>
<div
ref={triggerRef}
onClick={() => setOpen(!open())}
class="inline-block"
>
{local.trigger}
</div>
<Show when={open()}>
<Portal>
<div
ref={menuRef}
class="fixed z-50 min-w-[200px] bg-surface-1 border border-border-strong rounded-lg shadow-elevated py-1 animate-scale-in"
style={{
left: `${position().x}px`,
top: `${position().y}px`,
}}
role="menu"
>
<For each={local.items}>
{(item) => (
<Show
when={!item.divider}
fallback={<div class="my-1 h-px bg-border" role="separator" />}
>
<button
class={`w-full flex items-center gap-3 px-4 py-2 text-sm text-left transition-colors ${
item.disabled
? "opacity-50 cursor-not-allowed"
: item.danger
? "text-error hover:bg-error-muted"
: "text-text-primary hover:bg-surface-2"
}`}
onClick={() => handleItemClick(item)}
disabled={item.disabled}
role="menuitem"
>
<Show when={item.icon}>
<span class="flex-shrink-0">{item.icon}</span>
</Show>
<span class="flex-1">{item.label}</span>
</button>
</Show>
)}
</For>
</div>
</Portal>
</Show>
</>
);
}
+119
View File
@@ -0,0 +1,119 @@
import { type JSX, For, Show, splitProps } from "solid-js";
interface InputProps extends JSX.InputHTMLAttributes<HTMLInputElement> {
label?: string;
error?: string;
size?: "sm" | "md" | "lg";
}
const sizeClasses = {
sm: "input-sm",
md: "",
lg: "input-lg",
};
export function Input(props: InputProps) {
const [local, rest] = splitProps(props, ["label", "error", "size", "class"]);
const size = () => local.size ?? "md";
return (
<div class="w-full">
<Show when={local.label}>
<label class="label">{local.label}</label>
</Show>
<input
class={`input ${sizeClasses[size()]} ${local.error ? "border-error focus:border-error focus:ring-error/20" : ""} ${local.class ?? ""}`}
{...rest}
/>
<Show when={local.error}>
<p class="mt-1 text-xs text-error">{local.error}</p>
</Show>
</div>
);
}
interface TextareaProps extends JSX.TextareaHTMLAttributes<HTMLTextAreaElement> {
label?: string;
error?: string;
}
export function Textarea(props: TextareaProps) {
const [local, rest] = splitProps(props, ["label", "error", "class"]);
return (
<div class="w-full">
<Show when={local.label}>
<label class="label">{local.label}</label>
</Show>
<textarea
class={`textarea ${local.error ? "border-error focus:border-error focus:ring-error/20" : ""} ${local.class ?? ""}`}
{...rest}
/>
<Show when={local.error}>
<p class="mt-1 text-xs text-error">{local.error}</p>
</Show>
</div>
);
}
interface SelectProps extends JSX.SelectHTMLAttributes<HTMLSelectElement> {
label?: string;
error?: string;
options?: { value: string; label: string; disabled?: boolean }[];
}
export function Select(props: SelectProps) {
const [local, rest] = splitProps(props, ["label", "error", "options", "children", "class"]);
return (
<div class="w-full">
<Show when={local.label}>
<label class="label">{local.label}</label>
</Show>
<select
class={`select ${local.error ? "border-error focus:border-error focus:ring-error/20" : ""} ${local.class ?? ""}`}
{...rest}
>
<Show when={local.options}>
<For each={local.options}>
{(option) => (
<option value={option.value} disabled={option.disabled}>
{option.label}
</option>
)}
</For>
</Show>
{local.children}
</select>
<Show when={local.error}>
<p class="mt-1 text-xs text-error">{local.error}</p>
</Show>
</div>
);
}
interface FileInputProps extends JSX.InputHTMLAttributes<HTMLInputElement> {
label?: string;
error?: string;
}
export function FileInput(props: FileInputProps) {
const [local, rest] = splitProps(props, ["label", "error", "class"]);
return (
<div class="w-full">
<Show when={local.label}>
<label class="label">{local.label}</label>
</Show>
<input
type="file"
class={`input cursor-pointer file:mr-4 file:py-2 file:px-4 file:rounded-md file:border-0 file:text-sm file:font-medium file:bg-surface-2 file:text-text-secondary hover:file:bg-surface-3 ${local.error ? "border-error" : ""} ${local.class ?? ""}`}
{...rest}
/>
<Show when={local.error}>
<p class="mt-1 text-xs text-error">{local.error}</p>
</Show>
</div>
);
}
+253
View File
@@ -0,0 +1,253 @@
import { type JSX, Show, createSignal, For } from "solid-js";
interface NavItem {
id: string;
label: string;
icon?: JSX.Element;
badge?: string | number;
children?: NavItem[];
}
interface SidebarProps {
items: NavItem[];
activeId?: string;
onSelect?: (id: string) => void;
collapsed?: boolean;
onToggleCollapse?: () => void;
header?: JSX.Element;
footer?: JSX.Element;
}
function SidebarItem(props: { item: NavItem; activeId?: string; onSelect?: (id: string) => void; collapsed?: boolean }) {
const isActive = () => props.item.id === props.activeId;
const hasChildren = () => (props.item.children?.length ?? 0) > 0;
const [expanded, setExpanded] = createSignal(false);
return (
<div>
<button
class={`w-full text-left ${isActive() ? "sidebar-item-active" : "sidebar-item"}`}
onClick={() => {
if (hasChildren()) {
setExpanded(!expanded());
} else {
props.onSelect?.(props.item.id);
}
}}
aria-expanded={hasChildren() ? expanded() : undefined}
aria-current={isActive() ? "page" : undefined}
>
<Show when={props.item.icon}>
<span class="flex-shrink-0">{props.item.icon}</span>
</Show>
<Show when={!props.collapsed}>
<span class="flex-1 truncate">{props.item.label}</span>
</Show>
<Show when={props.item.badge && !props.collapsed}>
<span class="badge-neutral text-2xs">{props.item.badge}</span>
</Show>
<Show when={hasChildren() && !props.collapsed}>
<svg
class={`h-4 w-4 transition-transform ${expanded() ? "rotate-180" : ""}`}
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" />
</svg>
</Show>
</button>
<Show when={hasChildren() && expanded() && !props.collapsed}>
<div class="ml-4 mt-1 space-y-1">
<For each={props.item.children}>
{(child) => (
<button
class={`w-full text-left sidebar-item text-xs ${child.id === props.activeId ? "sidebar-item-active" : ""}`}
onClick={() => props.onSelect?.(child.id)}
>
{child.label}
</button>
)}
</For>
</div>
</Show>
</div>
);
}
export function Sidebar(props: SidebarProps) {
return (
<aside
class={`sidebar transition-all duration-fast ${props.collapsed ? "w-16" : "w-60"}`}
role="navigation"
aria-label="Main navigation"
>
<Show when={props.header}>
<div class="border-b border-border p-4 flex items-center justify-between">
{props.header}
</div>
</Show>
<nav class="sidebar-nav">
<div class="space-y-1">
<For each={props.items}>
{(item) => (
<SidebarItem
item={item}
activeId={props.activeId}
onSelect={props.onSelect}
collapsed={props.collapsed}
/>
)}
</For>
</div>
</nav>
<Show when={props.footer}>
<div class="border-t border-border p-4">{props.footer}</div>
</Show>
</aside>
);
}
interface HeaderProps {
title?: string;
subtitle?: string;
actions?: JSX.Element;
breadcrumbs?: { label: string; href?: string }[];
onMenuToggle?: () => void;
logo?: JSX.Element;
tabs?: { id: string; label: string }[];
activeTab?: string;
onTabChange?: (id: string) => void;
}
export function Header(props: HeaderProps) {
return (
<header class="top-nav">
<div class="top-nav-main">
<div class="flex items-center gap-6">
<Show when={props.logo}>
{props.logo}
</Show>
<Show when={props.breadcrumbs}>
<nav class="hidden items-center gap-2 text-sm sm:flex" aria-label="Breadcrumb">
<For each={props.breadcrumbs}>
{(crumb, index) => (
<>
<Show when={index() > 0}>
<span class="text-text-muted">/</span>
</Show>
<Show when={crumb.href} fallback={<span class="text-text-primary font-medium">{crumb.label}</span>}>
<a href={crumb.href} class="text-text-secondary hover:text-text-primary transition-colors">
{crumb.label}
</a>
</Show>
</>
)}
</For>
</nav>
</Show>
<Show when={props.title && !props.breadcrumbs}>
<div>
<h1 class="text-lg font-semibold text-text-primary">{props.title}</h1>
<Show when={props.subtitle}>
<p class="text-xs text-text-secondary">{props.subtitle}</p>
</Show>
</div>
</Show>
</div>
<Show when={props.actions}>
<div class="flex items-center gap-3">{props.actions}</div>
</Show>
</div>
<Show when={props.tabs && props.tabs.length > 0}>
<div class="top-nav-tabs">
<For each={props.tabs}>
{(tab) => (
<button
class={`top-nav-tab ${props.activeTab === tab.id ? 'top-nav-tab-active' : ''}`}
onClick={() => props.onTabChange?.(tab.id)}
>
{tab.label}
</button>
)}
</For>
</div>
</Show>
</header>
);
}
interface LayoutProps {
children: JSX.Element;
sidebar?: JSX.Element;
header?: JSX.Element;
sidebarCollapsed?: boolean;
}
export function Layout(props: LayoutProps) {
return (
<div class="flex h-screen flex-col overflow-hidden bg-bg-main">
<Show when={props.header}>
{props.header}
</Show>
<div class="flex flex-1 overflow-hidden">
<Show when={props.sidebar}>
{props.sidebar}
</Show>
<main class="main-content">{props.children}</main>
</div>
</div>
);
}
interface PageHeaderProps {
eyebrow?: string;
title: string;
description?: string;
actions?: JSX.Element;
}
export function PageHeader(props: PageHeaderProps) {
return (
<div class="mb-6 flex flex-col gap-3 md:flex-row md:items-start md:justify-between animate-fade-in">
<div class="space-y-1">
<Show when={props.eyebrow}>
<p class="text-xs font-semibold text-accent uppercase tracking-wider">{props.eyebrow}</p>
</Show>
<h1 class="text-2xl font-bold text-text-primary tracking-tight">{props.title}</h1>
<Show when={props.description}>
<p class="text-sm text-text-secondary max-w-2xl">{props.description}</p>
</Show>
</div>
<Show when={props.actions}>
<div class="flex flex-wrap gap-2">{props.actions}</div>
</Show>
</div>
);
}
interface EmptyStateProps {
icon?: JSX.Element;
title: string;
description?: string;
action?: JSX.Element;
}
export function EmptyState(props: EmptyStateProps) {
return (
<div class="flex flex-col items-center justify-center py-12 px-4 text-center animate-fade-in">
<Show when={props.icon}>
<div class="mb-4 text-text-muted opacity-40">{props.icon}</div>
</Show>
<h3 class="text-base font-semibold text-text-primary">{props.title}</h3>
<Show when={props.description}>
<p class="mt-1 text-sm text-text-secondary max-w-md">{props.description}</p>
</Show>
<Show when={props.action}>
<div class="mt-6">{props.action}</div>
</Show>
</div>
);
}
+118
View File
@@ -0,0 +1,118 @@
import { type JSX } from "solid-js";
interface LogoProps {
size?: "sm" | "md" | "lg";
showText?: boolean;
class?: string;
animated?: boolean;
}
const sizeClasses = {
sm: "h-7 w-7 text-xs",
md: "h-9 w-9 text-sm",
lg: "h-12 w-12 text-base",
};
const textSizeClasses = {
sm: "text-sm",
md: "text-lg",
lg: "text-xl",
};
export function Logo(props: LogoProps) {
const size = () => props.size ?? "md";
const showText = () => props.showText ?? true;
const animated = () => props.animated ?? true;
return (
<div class={`flex items-center gap-3 group ${props.class ?? ""}`}>
<div
class={`${sizeClasses[size()]} relative flex items-center justify-center rounded-xl bg-gradient-to-br from-accent via-accent-hover to-accent font-bold text-white shadow-lg ${animated() ? 'transition-all duration-300 group-hover:scale-110 group-hover:rotate-3 group-hover:shadow-xl' : ''}`}
style={{
"box-shadow": "0 4px 20px rgba(25, 163, 217, 0.3), 0 0 40px rgba(25, 163, 217, 0.15)",
}}
>
<span class="relative z-10 font-display font-extrabold">P</span>
{/* Animated glow effect */}
{animated() && (
<div
class="absolute inset-0 rounded-xl bg-gradient-to-br from-accent-hover to-accent opacity-0 group-hover:opacity-100 transition-opacity duration-300 blur-sm"
style={{ "z-index": "-1" }}
/>
)}
</div>
{showText() && (
<div class="flex flex-col leading-none">
<span
class={`${textSizeClasses[size()]} font-display font-bold tracking-tight bg-gradient-to-r from-text-primary via-accent to-text-primary bg-clip-text text-transparent ${animated() ? 'transition-all duration-300 group-hover:tracking-wide' : ''}`}
style={{
"background-size": "200% auto",
"animation": animated() ? "shimmer 3s linear infinite" : "none",
}}
>
PRIMORA
</span>
<span class="text-2xs text-text-muted font-medium tracking-widest uppercase mt-0.5">
Platform
</span>
</div>
)}
</div>
);
}
export function LogoIcon(props: { class?: string; animated?: boolean }) {
const animated = () => props.animated ?? false;
return (
<svg
class={`${props.class ?? ''} ${animated() ? 'transition-transform duration-300 hover:scale-110 hover:rotate-6' : ''}`}
viewBox="0 0 32 32"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<defs>
<linearGradient
id="logo-gradient"
x1="0"
y1="0"
x2="32"
y2="32"
gradientUnits="userSpaceOnUse"
>
<stop stop-color="#19a3d9" />
<stop offset="0.5" stop-color="#22b8f0" />
<stop offset="1" stop-color="#19a3d9" />
</linearGradient>
<filter id="glow">
<feGaussianBlur stdDeviation="2" result="coloredBlur"/>
<feMerge>
<feMergeNode in="coloredBlur"/>
<feMergeNode in="SourceGraphic"/>
</feMerge>
</filter>
</defs>
<rect width="32" height="32" rx="8" fill="url(#logo-gradient)" filter="url(#glow)" />
{/* P letter with modern design */}
<path
d="M10 9h7c3.866 0 7 3.134 7 7 0 3.866-3.134 7-7 7h-3v-5h3c1.657 0 3-1.343 3-3s-1.343-3-3-3h-5v14H10V9z"
fill="white"
opacity="0.95"
/>
{/* Accent dot */}
<circle cx="24" cy="24" r="2.5" fill="white" opacity="0.9">
{animated() && (
<animate
attributeName="opacity"
values="0.9;0.5;0.9"
dur="2s"
repeatCount="indefinite"
/>
)}
</circle>
</svg>
);
}
+138
View File
@@ -0,0 +1,138 @@
import { type JSX, For, Show, splitProps } from "solid-js";
type MessageVariant = "info" | "success" | "warning" | "error" | "neutral";
interface MessageProps extends JSX.HTMLAttributes<HTMLDivElement> {
variant?: MessageVariant;
title?: string;
icon?: JSX.Element;
dismissible?: boolean;
onDismiss?: () => void;
}
const variantClasses: Record<MessageVariant, string> = {
info: "message-info",
success: "message-success",
warning: "message-warning",
error: "message-error",
neutral: "message-neutral",
};
export function Message(props: MessageProps) {
const [local, rest] = splitProps(props, [
"variant",
"title",
"icon",
"dismissible",
"onDismiss",
"children",
"class",
]);
const variant = () => local.variant ?? "neutral";
return (
<div
class={`${variantClasses[variant()]} ${local.class ?? ""}`}
role="alert"
{...rest}
>
<div class="flex gap-3">
<Show when={local.icon}>
<div class="flex-shrink-0">{local.icon}</div>
</Show>
<div class="flex-1">
<Show when={local.title}>
<p class="font-medium">{local.title}</p>
</Show>
<div class={local.title ? "mt-1" : ""}>{local.children}</div>
</div>
<Show when={local.dismissible}>
<button
class="flex-shrink-0 opacity-70 hover:opacity-100 transition-opacity"
onClick={local.onDismiss}
aria-label="Dismiss"
>
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</Show>
</div>
</div>
);
}
interface LoadingProps {
text?: string;
size?: "sm" | "md" | "lg";
}
const sizeClasses = {
sm: "w-4 h-4",
md: "w-6 h-6",
lg: "w-8 h-8",
};
export function Loading(props: LoadingProps) {
const size = () => props.size ?? "md";
return (
<div class="flex items-center gap-3 text-text-secondary animate-fade-in">
<div class={`${sizeClasses[size()]} spinner`} />
<Show when={props.text}>
<span class="text-sm font-medium">{props.text}</span>
</Show>
</div>
);
}
interface SkeletonProps {
class?: string;
width?: string;
height?: string;
rounded?: "sm" | "md" | "lg" | "full";
}
const roundedClasses = {
sm: "rounded-sm",
md: "rounded-md",
lg: "rounded-lg",
full: "rounded-full",
};
export function Skeleton(props: SkeletonProps) {
const rounded = () => props.rounded ?? "md";
return (
<div
class={`skeleton shimmer ${roundedClasses[rounded()]} ${props.class ?? ""}`}
style={{
width: props.width,
height: props.height,
}}
/>
);
}
interface SkeletonCardProps {
lines?: number;
}
export function SkeletonCard(props: SkeletonCardProps) {
const lines = () => props.lines ?? 3;
return (
<div class="card space-y-3">
<Skeleton width="40%" height="14px" />
<For each={Array.from({ length: lines() })}>
{(_, i) => (
<Skeleton
width={i() === lines() - 1 ? "60%" : "100%"}
height="12px"
/>
)}
</For>
</div>
);
}
+157
View File
@@ -0,0 +1,157 @@
import { type JSX, Show, createEffect, onCleanup, splitProps } from "solid-js";
import { Portal } from "solid-js/web";
interface ModalProps extends JSX.HTMLAttributes<HTMLDivElement> {
open: boolean;
onClose: () => void;
title?: string;
description?: string;
size?: "sm" | "md" | "lg" | "xl" | "full";
closeOnEscape?: boolean;
closeOnBackdrop?: boolean;
showClose?: boolean;
}
const sizeClasses = {
sm: "max-w-md",
md: "max-w-lg",
lg: "max-w-2xl",
xl: "max-w-4xl",
full: "max-w-full mx-4",
};
export function Modal(props: ModalProps) {
const [local, rest] = splitProps(props, [
"open",
"onClose",
"title",
"description",
"size",
"closeOnEscape",
"closeOnBackdrop",
"showClose",
"children",
"class",
]);
const size = () => local.size ?? "md";
const closeOnEscape = () => local.closeOnEscape ?? true;
const closeOnBackdrop = () => local.closeOnBackdrop ?? true;
const showClose = () => local.showClose ?? true;
createEffect(() => {
if (local.open) {
document.body.style.overflow = "hidden";
} else {
document.body.style.overflow = "";
}
});
createEffect(() => {
if (!local.open || !closeOnEscape()) return;
const handleEscape = (e: KeyboardEvent) => {
if (e.key === "Escape") {
local.onClose();
}
};
document.addEventListener("keydown", handleEscape);
onCleanup(() => document.removeEventListener("keydown", handleEscape));
});
onCleanup(() => {
document.body.style.overflow = "";
});
return (
<Show when={local.open}>
<Portal>
<div
class="fixed inset-0 z-50 flex items-center justify-center p-4 animate-fade-in"
role="dialog"
aria-modal="true"
aria-labelledby={local.title ? "modal-title" : undefined}
aria-describedby={local.description ? "modal-description" : undefined}
>
{/* Backdrop */}
<div
class="absolute inset-0 bg-black/60 backdrop-blur-sm"
onClick={() => closeOnBackdrop() && local.onClose()}
aria-hidden="true"
/>
{/* Modal content */}
<div
class={`relative w-full ${sizeClasses[size()]} bg-surface-1 border border-border-strong rounded-xl shadow-elevated animate-scale-in ${local.class ?? ""}`}
{...rest}
>
{/* Header */}
<Show when={local.title || showClose()}>
<div class="flex items-start justify-between p-6 border-b border-border">
<div class="flex-1">
<Show when={local.title}>
<h2 id="modal-title" class="text-xl font-semibold text-text-primary">
{local.title}
</h2>
</Show>
<Show when={local.description}>
<p id="modal-description" class="mt-1.5 text-sm text-text-secondary">
{local.description}
</p>
</Show>
</div>
<Show when={showClose()}>
<button
class="ml-4 p-2 rounded-lg text-text-muted hover:text-text-primary hover:bg-surface-2 transition-colors"
onClick={local.onClose}
aria-label="Close modal"
>
<svg class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</Show>
</div>
</Show>
{/* Body */}
<div class="p-6">{local.children}</div>
</div>
</div>
</Portal>
</Show>
);
}
interface ModalFooterProps extends JSX.HTMLAttributes<HTMLDivElement> {
align?: "left" | "right" | "between" | "center";
}
export function ModalFooter(props: ModalFooterProps) {
const [local, rest] = splitProps(props, ["align", "children", "class"]);
const alignClass = () => {
switch (local.align) {
case "left":
return "justify-start";
case "right":
return "justify-end";
case "between":
return "justify-between";
case "center":
return "justify-center";
default:
return "justify-end";
}
};
return (
<div
class={`flex items-center gap-3 px-6 pb-6 ${alignClass()} ${local.class ?? ""}`}
{...rest}
>
{local.children}
</div>
);
}
@@ -0,0 +1,83 @@
import { Show, createSignal, onMount } from "solid-js";
import { enableDemoMode } from "../lib/demo-mode";
interface NetworkErrorProps {
error: string;
onRetry?: () => void;
onDismiss?: () => void;
}
export function NetworkError(props: NetworkErrorProps) {
const [visible, setVisible] = createSignal(true);
const handleDismiss = () => {
setVisible(false);
props.onDismiss?.();
};
const handleDemoMode = () => {
enableDemoMode();
};
return (
<Show when={visible()}>
<div class="network-error-toast">
<div class="network-error-content">
<svg class="network-error-icon" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
</svg>
<div class="network-error-text">
<div class="network-error-title">Connection Error</div>
<div class="network-error-message">{props.error}</div>
<div class="network-error-actions">
<Show when={props.onRetry}>
<button class="btn-sm btn-secondary" onClick={props.onRetry}>
Retry
</button>
</Show>
<button class="btn-sm btn-primary" onClick={handleDemoMode}>
Try Demo Mode
</button>
<button class="btn-sm btn-ghost" onClick={handleDismiss}>
Dismiss
</button>
</div>
</div>
</div>
</div>
</Show>
);
}
interface DemoBannerProps {
onExit?: () => void;
}
export function DemoBanner(props: DemoBannerProps) {
const [visible, setVisible] = createSignal(true);
const handleClose = () => {
setVisible(false);
};
return (
<Show when={visible()}>
<div class="demo-banner">
<svg class="demo-banner-icon" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<span>Demo Mode Active - All data is simulated</span>
<Show when={props.onExit}>
<button class="btn-sm btn-ghost text-white" onClick={props.onExit}>
Exit Demo
</button>
</Show>
<button class="demo-banner-close" onClick={handleClose} aria-label="Close">
<svg width="14" height="14" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
</Show>
);
}
@@ -0,0 +1,157 @@
import { Show, For, createSignal } from "solid-js";
import { Portal } from "solid-js/web";
export interface Notification {
id: string;
type: "success" | "error" | "warning" | "info";
title: string;
message?: string;
duration?: number;
action?: {
label: string;
onClick: () => void;
};
}
const [notifications, setNotifications] = createSignal<Notification[]>([]);
export function addNotification(notification: Omit<Notification, "id">) {
const id = Math.random().toString(36).substring(7);
const newNotification: Notification = {
...notification,
id,
duration: notification.duration ?? 5000,
};
setNotifications(prev => [...prev, newNotification]);
if (newNotification.duration > 0) {
setTimeout(() => {
removeNotification(id);
}, newNotification.duration);
}
return id;
}
export function removeNotification(id: string) {
setNotifications(prev => prev.filter(n => n.id !== id));
}
export function clearNotifications() {
setNotifications([]);
}
export function NotificationCenter() {
const getIcon = (type: Notification["type"]) => {
switch (type) {
case "success":
return (
<svg class="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
);
case "error":
return (
<svg class="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
);
case "warning":
return (
<svg class="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
</svg>
);
case "info":
return (
<svg class="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
);
}
};
const getColors = (type: Notification["type"]) => {
switch (type) {
case "success":
return "bg-green-50 border-green-200 text-green-800";
case "error":
return "bg-red-50 border-red-200 text-red-800";
case "warning":
return "bg-yellow-50 border-yellow-200 text-yellow-800";
case "info":
return "bg-blue-50 border-blue-200 text-blue-800";
}
};
const getIconColors = (type: Notification["type"]) => {
switch (type) {
case "success":
return "text-green-600";
case "error":
return "text-red-600";
case "warning":
return "text-yellow-600";
case "info":
return "text-blue-600";
}
};
return (
<Portal>
<div class="fixed top-4 right-4 z-50 space-y-3 max-w-md">
<For each={notifications()}>
{(notification) => (
<div
class={`${getColors(notification.type)} border rounded-lg shadow-lg p-4 animate-slide-in-right`}
>
<div class="flex items-start gap-3">
<div class={`flex-shrink-0 ${getIconColors(notification.type)}`}>
{getIcon(notification.type)}
</div>
<div class="flex-1 min-w-0">
<h4 class="font-semibold text-sm">{notification.title}</h4>
<Show when={notification.message}>
<p class="text-sm mt-1 opacity-90">{notification.message}</p>
</Show>
<Show when={notification.action}>
<button
onClick={notification.action!.onClick}
class="text-sm font-medium mt-2 underline hover:no-underline"
>
{notification.action!.label}
</button>
</Show>
</div>
<button
onClick={() => removeNotification(notification.id)}
class="flex-shrink-0 opacity-60 hover:opacity-100 transition-opacity"
>
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
</div>
)}
</For>
</div>
</Portal>
);
}
// Convenience functions
export const notify = {
success: (title: string, message?: string, options?: Partial<Notification>) =>
addNotification({ type: "success", title, message, ...options }),
error: (title: string, message?: string, options?: Partial<Notification>) =>
addNotification({ type: "error", title, message, duration: 7000, ...options }),
warning: (title: string, message?: string, options?: Partial<Notification>) =>
addNotification({ type: "warning", title, message, ...options }),
info: (title: string, message?: string, options?: Partial<Notification>) =>
addNotification({ type: "info", title, message, ...options }),
};
@@ -0,0 +1,130 @@
import { Show, createSignal } from "solid-js";
import { Button, Input, Modal } from "./index";
interface OnboardingModalProps {
isOpen: boolean;
projectName: string;
onClose: () => void;
}
export function OnboardingModal(props: OnboardingModalProps) {
const [step, setStep] = createSignal(1);
const [apiKey, setApiKey] = createSignal("");
const handleNext = () => {
if (step() < 3) {
setStep(step() + 1);
} else {
props.onClose();
}
};
const handleSkip = () => {
props.onClose();
};
return (
<Modal open={props.isOpen} onClose={props.onClose} title="Get Started with Your Project">
<div class="space-y-6">
<Show when={step() === 1}>
<div class="space-y-4">
<div class="text-center">
<div class="mx-auto w-16 h-16 bg-blue-100 rounded-full flex items-center justify-center mb-4">
<svg class="w-8 h-8 text-blue-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z" />
</svg>
</div>
<h3 class="text-lg font-semibold mb-2">Welcome to {props.projectName}!</h3>
<p class="text-gray-600 text-sm">
Let's get you set up in just a few steps. You'll be able to create API keys, set up storage, and start building.
</p>
</div>
<div class="bg-gray-50 rounded-lg p-4 space-y-2">
<div class="flex items-center gap-3">
<div class="w-6 h-6 rounded-full bg-blue-600 text-white flex items-center justify-center text-xs font-semibold">1</div>
<span class="text-sm">Create your first API key</span>
</div>
<div class="flex items-center gap-3">
<div class="w-6 h-6 rounded-full bg-gray-300 text-white flex items-center justify-center text-xs font-semibold">2</div>
<span class="text-sm">Set up storage buckets</span>
</div>
<div class="flex items-center gap-3">
<div class="w-6 h-6 rounded-full bg-gray-300 text-white flex items-center justify-center text-xs font-semibold">3</div>
<span class="text-sm">Connect your application</span>
</div>
</div>
</div>
</Show>
<Show when={step() === 2}>
<div class="space-y-4">
<div class="text-center mb-4">
<div class="mx-auto w-16 h-16 bg-green-100 rounded-full flex items-center justify-center mb-4">
<svg class="w-8 h-8 text-green-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1121 9z" />
</svg>
</div>
<h3 class="text-lg font-semibold mb-2">Create Your First API Key</h3>
<p class="text-gray-600 text-sm">
API keys allow your applications to authenticate with Primora services.
</p>
</div>
<div class="bg-blue-50 border border-blue-200 rounded-lg p-4">
<p class="text-sm text-blue-800">
<strong>Tip:</strong> You can create API keys from the Settings tab. Each key can have different permissions and scopes.
</p>
</div>
</div>
</Show>
<Show when={step() === 3}>
<div class="space-y-4">
<div class="text-center mb-4">
<div class="mx-auto w-16 h-16 bg-purple-100 rounded-full flex items-center justify-center mb-4">
<svg class="w-8 h-8 text-purple-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 20l4-16m4 4l4 4-4 4M6 16l-4-4 4-4" />
</svg>
</div>
<h3 class="text-lg font-semibold mb-2">Connect Your Application</h3>
<p class="text-gray-600 text-sm mb-4">
Use the following code snippet to connect to your project:
</p>
</div>
<div class="bg-gray-900 rounded-lg p-4 text-sm font-mono text-gray-100 overflow-x-auto">
<pre>{`import { PrimoraClient } from '@primora/client';
const client = new PrimoraClient({
apiKey: 'your-api-key',
projectId: '${props.projectName.toLowerCase().replace(/\s+/g, '-')}'
});
// Upload a file
await client.storage.upload('bucket-name', file);`}</pre>
</div>
<div class="bg-yellow-50 border border-yellow-200 rounded-lg p-4">
<p class="text-sm text-yellow-800">
<strong>Documentation:</strong> Visit our docs to learn more about authentication, storage, and other features.
</p>
</div>
</div>
</Show>
<div class="flex justify-between pt-4 border-t">
<Button variant="ghost" onClick={handleSkip}>
Skip for now
</Button>
<div class="flex gap-2">
<Show when={step() > 1}>
<Button variant="outline" onClick={() => setStep(step() - 1)}>
Back
</Button>
</Show>
<Button onClick={handleNext}>
{step() === 3 ? "Get Started" : "Next"}
</Button>
</div>
</div>
</div>
</Modal>
);
}
+163
View File
@@ -0,0 +1,163 @@
import { type JSX, Show, splitProps } from "solid-js";
interface ProgressProps extends JSX.HTMLAttributes<HTMLDivElement> {
value: number;
max?: number;
size?: "sm" | "md" | "lg";
variant?: "default" | "success" | "warning" | "error";
showLabel?: boolean;
label?: string;
animated?: boolean;
}
const sizeClasses = {
sm: "h-1",
md: "h-2",
lg: "h-3",
};
const variantClasses = {
default: "bg-accent",
success: "bg-success",
warning: "bg-warning",
error: "bg-error",
};
export function Progress(props: ProgressProps) {
const [local, rest] = splitProps(props, [
"value",
"max",
"size",
"variant",
"showLabel",
"label",
"animated",
"class",
]);
const max = () => local.max ?? 100;
const size = () => local.size ?? "md";
const variant = () => local.variant ?? "default";
const percentage = () => Math.min(100, Math.max(0, (local.value / max()) * 100));
return (
<div class={`w-full ${local.class ?? ""}`} {...rest}>
<Show when={local.showLabel || local.label}>
<div class="flex items-center justify-between mb-2">
<span class="text-xs font-medium text-text-secondary">{local.label ?? "Progress"}</span>
<span class="text-xs font-medium text-text-primary">{Math.round(percentage())}%</span>
</div>
</Show>
<div
class={`w-full bg-surface-2 rounded-full overflow-hidden ${sizeClasses[size()]}`}
role="progressbar"
aria-valuenow={local.value}
aria-valuemin={0}
aria-valuemax={max()}
>
<div
class={`h-full ${variantClasses[variant()]} transition-all duration-300 ease-out ${local.animated ? "animate-pulse" : ""}`}
style={{ width: `${percentage()}%` }}
/>
</div>
</div>
);
}
interface CircularProgressProps {
value: number;
max?: number;
size?: number;
strokeWidth?: number;
variant?: "default" | "success" | "warning" | "error";
showLabel?: boolean;
}
export function CircularProgress(props: CircularProgressProps) {
const max = () => props.max ?? 100;
const size = () => props.size ?? 64;
const strokeWidth = () => props.strokeWidth ?? 4;
const variant = () => props.variant ?? "default";
const percentage = () => Math.min(100, Math.max(0, (props.value / max()) * 100));
const radius = () => (size() - strokeWidth()) / 2;
const circumference = () => 2 * Math.PI * radius();
const offset = () => circumference() - (percentage() / 100) * circumference();
const colorMap = {
default: "var(--accent)",
success: "var(--success)",
warning: "var(--warning)",
error: "var(--error)",
};
return (
<div class="relative inline-flex items-center justify-center">
<svg
width={size()}
height={size()}
class="transform -rotate-90"
>
{/* Background circle */}
<circle
cx={size() / 2}
cy={size() / 2}
r={radius()}
stroke="var(--surface-2)"
stroke-width={strokeWidth()}
fill="none"
/>
{/* Progress circle */}
<circle
cx={size() / 2}
cy={size() / 2}
r={radius()}
stroke={colorMap[variant()]}
stroke-width={strokeWidth()}
fill="none"
stroke-dasharray={circumference()}
stroke-dashoffset={offset()}
stroke-linecap="round"
class="transition-all duration-300 ease-out"
/>
</svg>
<Show when={props.showLabel}>
<div class="absolute inset-0 flex items-center justify-center">
<span class="text-sm font-semibold text-text-primary">
{Math.round(percentage())}%
</span>
</div>
</Show>
</div>
);
}
interface SpinnerProps {
size?: "sm" | "md" | "lg";
variant?: "default" | "primary";
}
const spinnerSizeClasses = {
sm: "w-4 h-4 border-2",
md: "w-6 h-6 border-2",
lg: "w-8 h-8 border-[3px]",
};
export function Spinner(props: SpinnerProps) {
const size = () => props.size ?? "md";
const variant = () => props.variant ?? "default";
return (
<div
class={`${spinnerSizeClasses[size()]} rounded-full animate-spin ${
variant() === "primary"
? "border-accent border-t-transparent"
: "border-surface-2 border-t-accent"
}`}
role="status"
aria-label="Loading"
>
<span class="sr-only">Loading...</span>
</div>
);
}
@@ -0,0 +1,178 @@
import { Show, For } from "solid-js";
import { Card, StatCard, Badge, Button } from "./index";
import type { ProjectOverview } from "@primora/api-client";
interface ProjectDashboardProps {
project: { id: string; name: string; slug: string; description?: string };
overview?: ProjectOverview;
onNavigate: (view: string) => void;
}
export function ProjectDashboard(props: ProjectDashboardProps) {
const stats = () => [
{
label: "Storage",
value: props.overview?.storage_buckets_count ?? 0,
unit: "buckets",
icon: (
<svg class="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 8h14M5 8a2 2 0 110-4h14a2 2 0 110 4M5 8v10a2 2 0 002 2h10a2 2 0 002-2V8m-9 3h4m-4 4h4" />
</svg>
),
},
{
label: "API Keys",
value: props.overview?.api_keys_count ?? 0,
unit: "keys",
icon: (
<svg class="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1121 9z" />
</svg>
),
},
{
label: "Members",
value: props.overview?.project_members_count ?? 0,
unit: "users",
icon: (
<svg class="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197M13 7a4 4 0 11-8 0 4 4 0 018 0z" />
</svg>
),
},
{
label: "Audit Logs",
value: props.overview?.audit_logs_count ?? 0,
unit: "events",
icon: (
<svg class="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" 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>
),
},
];
return (
<div class="space-y-6">
{/* Project Header */}
<div class="flex items-start justify-between">
<div>
<h1 class="text-2xl font-bold text-gray-900">{props.project.name}</h1>
<Show when={props.project.description}>
<p class="text-gray-600 mt-1">{props.project.description}</p>
</Show>
<div class="flex items-center gap-2 mt-2">
<Badge variant="secondary">{props.project.slug}</Badge>
</div>
</div>
</div>
{/* Stats Grid */}
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
<For each={stats()}>
{(stat) => (
<Card class="p-6">
<div class="flex items-center justify-between">
<div>
<p class="text-sm text-gray-600 mb-1">{stat.label}</p>
<p class="text-3xl font-bold text-gray-900">{stat.value}</p>
<p class="text-xs text-gray-500 mt-1">{stat.unit}</p>
</div>
<div class="text-gray-400">{stat.icon}</div>
</div>
</Card>
)}
</For>
</div>
{/* Usage Charts */}
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
<Card class="p-6">
<h3 class="text-lg font-semibold mb-4">Bandwidth</h3>
<div class="h-48 flex items-center justify-center bg-gray-50 rounded-lg">
<div class="text-center text-gray-500">
<svg class="w-12 h-12 mx-auto mb-2 opacity-50" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z" />
</svg>
<p class="text-sm">No data to show</p>
</div>
</div>
</Card>
<Card class="p-6">
<h3 class="text-lg font-semibold mb-4">Requests</h3>
<div class="h-48 flex items-center justify-center bg-gray-50 rounded-lg">
<div class="text-center text-gray-500">
<svg class="w-12 h-12 mx-auto mb-2 opacity-50" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 12l3-3 3 3 4-4M8 21l4-4 4 4M3 4h18M4 4h16v12a1 1 0 01-1 1H5a1 1 0 01-1-1V4z" />
</svg>
<p class="text-sm">No data to show</p>
</div>
</div>
</Card>
</div>
{/* Quick Actions */}
<Card class="p-6">
<h3 class="text-lg font-semibold mb-4">Quick Start</h3>
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
<button
onClick={() => props.onNavigate("storage")}
class="p-4 border border-gray-200 rounded-lg hover:border-blue-500 hover:bg-blue-50 transition-colors text-left"
>
<div class="flex items-center gap-3 mb-2">
<div class="w-10 h-10 bg-blue-100 rounded-lg flex items-center justify-center">
<svg class="w-5 h-5 text-blue-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 8h14M5 8a2 2 0 110-4h14a2 2 0 110 4M5 8v10a2 2 0 002 2h10a2 2 0 002-2V8m-9 3h4m-4 4h4" />
</svg>
</div>
<h4 class="font-semibold">Create Bucket</h4>
</div>
<p class="text-sm text-gray-600">Set up storage for your files and assets</p>
</button>
<button
onClick={() => props.onNavigate("settings")}
class="p-4 border border-gray-200 rounded-lg hover:border-green-500 hover:bg-green-50 transition-colors text-left"
>
<div class="flex items-center gap-3 mb-2">
<div class="w-10 h-10 bg-green-100 rounded-lg flex items-center justify-center">
<svg class="w-5 h-5 text-green-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1121 9z" />
</svg>
</div>
<h4 class="font-semibold">Generate API Key</h4>
</div>
<p class="text-sm text-gray-600">Create keys to authenticate your apps</p>
</button>
<button
onClick={() => props.onNavigate("members")}
class="p-4 border border-gray-200 rounded-lg hover:border-purple-500 hover:bg-purple-50 transition-colors text-left"
>
<div class="flex items-center gap-3 mb-2">
<div class="w-10 h-10 bg-purple-100 rounded-lg flex items-center justify-center">
<svg class="w-5 h-5 text-purple-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="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>
<h4 class="font-semibold">Invite Members</h4>
</div>
<p class="text-sm text-gray-600">Add team members to collaborate</p>
</button>
</div>
</Card>
{/* Documentation Link */}
<Card class="p-6 bg-gradient-to-r from-blue-50 to-purple-50 border-blue-200">
<div class="flex items-center justify-between">
<div>
<h3 class="text-lg font-semibold mb-1">Need Help Getting Started?</h3>
<p class="text-sm text-gray-600">Check out our documentation and guides</p>
</div>
<Button variant="outline">View Docs</Button>
</div>
</Card>
</div>
);
}
+241
View File
@@ -0,0 +1,241 @@
import { type JSX, For, Show, splitProps } from "solid-js";
interface Column<T> {
key: keyof T | string;
header: string;
width?: string;
align?: "left" | "center" | "right";
render?: (value: T[keyof T], row: T, index: number) => JSX.Element;
}
interface TableProps<T> extends JSX.HTMLAttributes<HTMLTableElement> {
columns: Column<T>[];
data: T[];
rowKey?: (row: T) => string | number;
onRowClick?: (row: T) => void;
loading?: boolean;
emptyMessage?: string;
stickyHeader?: boolean;
}
export function Table<T extends Record<string, unknown>>(props: TableProps<T>) {
const [local, rest] = splitProps(props, [
"columns",
"data",
"rowKey",
"onRowClick",
"loading",
"emptyMessage",
"stickyHeader",
"children",
"class",
]);
const getKeyValue = (row: T, key: keyof T | string): unknown => {
if (typeof key === "string" && key.includes(".")) {
const keys = key.split(".");
let value: unknown = row;
for (const k of keys) {
value = (value as Record<string, unknown>)?.[k];
}
return value;
}
return row[key as keyof T];
};
const alignClass = (align?: "left" | "center" | "right") => {
switch (align) {
case "center":
return "text-center";
case "right":
return "text-right";
default:
return "text-left";
}
};
return (
<div class="table-container">
<table class={`table ${local.class ?? ""}`} {...rest}>
<thead class={local.stickyHeader ? "sticky top-0 z-10" : ""}>
<tr>
<For each={local.columns}>
{(column) => (
<th
class={alignClass(column.align)}
style={{ width: column.width }}
>
{column.header}
</th>
)}
</For>
</tr>
</thead>
<tbody>
<Show when={!local.loading && local.data.length === 0}>
<tr>
<td
colspan={local.columns.length}
class="py-8 text-center text-text-muted"
>
{local.emptyMessage ?? "No data available"}
</td>
</tr>
</Show>
<For each={local.data}>
{(row, index) => (
<tr
class={local.onRowClick ? "cursor-pointer" : ""}
onClick={() => local.onRowClick?.(row)}
data-row-key={local.rowKey?.(row)}
>
<For each={local.columns}>
{(column) => {
const value = getKeyValue(row, column.key);
return (
<td class={alignClass(column.align)}>
<Show
when={column.render}
fallback={String(value ?? "")}
>
{column.render!(
value as T[keyof T],
row,
index()
)}
</Show>
</td>
);
}}
</For>
</tr>
)}
</For>
</tbody>
</table>
</div>
);
}
interface DataTableProps<T> extends JSX.HTMLAttributes<HTMLDivElement> {
columns: Column<T>[];
data: T[];
rowKey?: (row: T) => string | number;
onRowClick?: (row: T) => void;
loading?: boolean;
emptyMessage?: string;
pageSize?: number;
showPagination?: boolean;
}
export function DataTable<T extends Record<string, unknown>>(
props: DataTableProps<T>
) {
const [local, rest] = splitProps(props, [
"columns",
"data",
"rowKey",
"onRowClick",
"loading",
"emptyMessage",
"pageSize",
"showPagination",
"children",
"class",
]);
return (
<div class={`space-y-4 ${local.class ?? ""}`} {...rest}>
<Table
columns={local.columns}
data={local.data}
rowKey={local.rowKey}
onRowClick={local.onRowClick}
loading={local.loading}
emptyMessage={local.emptyMessage}
stickyHeader
/>
<Show when={local.children}>{local.children}</Show>
</div>
);
}
interface PaginationProps {
currentPage: number;
totalPages: number;
onPageChange: (page: number) => void;
showPageNumbers?: boolean;
}
export function Pagination(props: PaginationProps) {
const pages = () => {
const pages: (number | "ellipsis")[] = [];
const total = props.totalPages;
const current = props.currentPage;
if (total <= 7) {
for (let i = 1; i <= total; i++) pages.push(i);
} else {
pages.push(1);
if (current > 3) pages.push("ellipsis");
for (
let i = Math.max(2, current - 1);
i <= Math.min(total - 1, current + 1);
i++
) {
pages.push(i);
}
if (current < total - 2) pages.push("ellipsis");
pages.push(total);
}
return pages;
};
return (
<nav class="flex items-center justify-between" aria-label="Pagination">
<div class="text-xs text-text-muted">
Page {props.currentPage} of {props.totalPages}
</div>
<div class="flex items-center gap-1">
<button
class="btn-ghost btn-sm"
onClick={() => props.onPageChange(props.currentPage - 1)}
disabled={props.currentPage === 1}
aria-label="Previous page"
>
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7" />
</svg>
</button>
<Show when={props.showPageNumbers ?? true}>
<For each={pages()}>
{(page) =>
page === "ellipsis" ? (
<span class="px-2 text-text-muted">...</span>
) : (
<button
class={`btn-sm ${page === props.currentPage ? "btn-primary" : "btn-ghost"}`}
onClick={() => props.onPageChange(page)}
aria-current={page === props.currentPage ? "page" : undefined}
>
{page}
</button>
)
}
</For>
</Show>
<button
class="btn-ghost btn-sm"
onClick={() => props.onPageChange(props.currentPage + 1)}
disabled={props.currentPage === props.totalPages}
aria-label="Next page"
>
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" />
</svg>
</button>
</div>
</nav>
);
}
+138
View File
@@ -0,0 +1,138 @@
import { type JSX, For, Show, createSignal, splitProps } from "solid-js";
interface Tab {
id: string;
label: string;
icon?: JSX.Element;
badge?: string | number;
disabled?: boolean;
content?: JSX.Element;
}
interface TabsProps {
tabs: Tab[];
defaultTab?: string;
activeTab?: string;
onChange?: (tabId: string) => void;
variant?: "default" | "pills" | "underline";
size?: "sm" | "md" | "lg";
}
export function Tabs(props: TabsProps) {
const [local] = splitProps(props, ["tabs", "defaultTab", "activeTab", "onChange", "variant", "size"]);
const [internalActiveTab, setInternalActiveTab] = createSignal(local.defaultTab ?? local.tabs[0]?.id);
const activeTab = () => local.activeTab ?? internalActiveTab();
const variant = () => local.variant ?? "default";
const size = () => local.size ?? "md";
const handleTabChange = (tabId: string) => {
setInternalActiveTab(tabId);
local.onChange?.(tabId);
};
const sizeClasses = {
sm: "text-xs px-3 py-1.5",
md: "text-sm px-4 py-2",
lg: "text-base px-5 py-2.5",
};
const getTabClasses = (tab: Tab) => {
const isActive = activeTab() === tab.id;
const base = `${sizeClasses[size()]} font-medium transition-all duration-fast flex items-center gap-2`;
if (tab.disabled) {
return `${base} opacity-50 cursor-not-allowed`;
}
switch (variant()) {
case "pills":
return `${base} rounded-lg ${
isActive
? "bg-accent text-white shadow-sm"
: "text-text-secondary hover:text-text-primary hover:bg-surface-2"
}`;
case "underline":
return `${base} border-b-2 ${
isActive
? "border-accent text-accent"
: "border-transparent text-text-secondary hover:text-text-primary hover:border-border-hover"
}`;
default:
return `${base} rounded-lg ${
isActive
? "bg-surface-2 text-text-primary"
: "text-text-secondary hover:text-text-primary hover:bg-surface-1"
}`;
}
};
return (
<div class="w-full">
<div
class={`flex gap-1 ${variant() === "underline" ? "border-b border-border" : ""}`}
role="tablist"
>
<For each={local.tabs}>
{(tab) => (
<button
class={getTabClasses(tab)}
onClick={() => !tab.disabled && handleTabChange(tab.id)}
disabled={tab.disabled}
role="tab"
aria-selected={activeTab() === tab.id}
aria-controls={`panel-${tab.id}`}
id={`tab-${tab.id}`}
>
<Show when={tab.icon}>
<span class="flex-shrink-0">{tab.icon}</span>
</Show>
<span>{tab.label}</span>
<Show when={tab.badge}>
<span class="badge-neutral text-2xs">{tab.badge}</span>
</Show>
</button>
)}
</For>
</div>
<div class="mt-4">
<For each={local.tabs}>
{(tab) => (
<Show when={activeTab() === tab.id}>
<div
role="tabpanel"
id={`panel-${tab.id}`}
aria-labelledby={`tab-${tab.id}`}
class="animate-fade-in"
>
{tab.content}
</div>
</Show>
)}
</For>
</div>
</div>
);
}
interface TabPanelProps extends JSX.HTMLAttributes<HTMLDivElement> {
value: string;
activeValue: string;
}
export function TabPanel(props: TabPanelProps) {
const [local, rest] = splitProps(props, ["value", "activeValue", "children", "class"]);
return (
<Show when={local.value === local.activeValue}>
<div
role="tabpanel"
class={`animate-fade-in ${local.class ?? ""}`}
{...rest}
>
{local.children}
</div>
</Show>
);
}
+138
View File
@@ -0,0 +1,138 @@
import { type JSX, For, Show, createSignal } from "solid-js";
import { Portal } from "solid-js/web";
type ToastVariant = "info" | "success" | "warning" | "error";
interface Toast {
id: string;
variant: ToastVariant;
title?: string;
message: string;
duration?: number;
icon?: JSX.Element;
}
const [toasts, setToasts] = createSignal<Toast[]>([]);
const variantConfig = {
info: {
bg: "bg-info-muted",
border: "border-info/25",
text: "text-info",
icon: (
<svg class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
),
},
success: {
bg: "bg-success-muted",
border: "border-success/25",
text: "text-success",
icon: (
<svg class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
),
},
warning: {
bg: "bg-warning-muted",
border: "border-warning/25",
text: "text-warning",
icon: (
<svg class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
</svg>
),
},
error: {
bg: "bg-error-muted",
border: "border-error/25",
text: "text-error",
icon: (
<svg class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
),
},
};
export const toast = {
show: (options: Omit<Toast, "id">) => {
const id = Math.random().toString(36).substring(7);
const duration = options.duration ?? 5000;
setToasts((prev) => [...prev, { ...options, id }]);
if (duration > 0) {
setTimeout(() => {
toast.dismiss(id);
}, duration);
}
return id;
},
success: (message: string, title?: string, duration?: number) => {
return toast.show({ variant: "success", message, title, duration });
},
error: (message: string, title?: string, duration?: number) => {
return toast.show({ variant: "error", message, title, duration });
},
warning: (message: string, title?: string, duration?: number) => {
return toast.show({ variant: "warning", message, title, duration });
},
info: (message: string, title?: string, duration?: number) => {
return toast.show({ variant: "info", message, title, duration });
},
dismiss: (id: string) => {
setToasts((prev) => prev.filter((t) => t.id !== id));
},
dismissAll: () => {
setToasts([]);
},
};
export function ToastContainer() {
return (
<Portal>
<div
class="fixed bottom-4 right-4 z-50 flex flex-col gap-2 max-w-md w-full pointer-events-none"
aria-live="polite"
aria-atomic="true"
>
<For each={toasts()}>
{(toast) => {
const config = variantConfig[toast.variant];
return (
<div
class={`${config.bg} ${config.border} border rounded-lg shadow-lg p-4 flex gap-3 animate-slide-up pointer-events-auto`}
role="alert"
>
<div class={`flex-shrink-0 ${config.text}`}>
{toast.icon ?? config.icon}
</div>
<div class="flex-1 min-w-0">
<Show when={toast.title}>
<p class={`font-semibold text-sm ${config.text}`}>{toast.title}</p>
</Show>
<p class={`text-sm ${toast.title ? "mt-1" : ""} ${config.text}`}>
{toast.message}
</p>
</div>
<button
class={`flex-shrink-0 ${config.text} opacity-70 hover:opacity-100 transition-opacity`}
onClick={() => toast.dismiss(toast.id)}
aria-label="Dismiss notification"
>
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
);
}}
</For>
</div>
</Portal>
);
}
+108
View File
@@ -0,0 +1,108 @@
import { type JSX, Show, createSignal, splitProps, onMount, onCleanup } from "solid-js";
import { Portal } from "solid-js/web";
interface TooltipProps {
content: string | JSX.Element;
placement?: "top" | "bottom" | "left" | "right";
delay?: number;
children: JSX.Element;
disabled?: boolean;
}
export function Tooltip(props: TooltipProps) {
const [local] = splitProps(props, ["content", "placement", "delay", "children", "disabled"]);
const [show, setShow] = createSignal(false);
const [position, setPosition] = createSignal({ x: 0, y: 0 });
let triggerRef: HTMLElement | undefined;
let tooltipRef: HTMLDivElement | undefined;
let timeoutId: number | undefined;
const placement = () => local.placement ?? "top";
const delay = () => local.delay ?? 200;
const calculatePosition = () => {
if (!triggerRef || !tooltipRef) return;
const triggerRect = triggerRef.getBoundingClientRect();
const tooltipRect = tooltipRef.getBoundingClientRect();
const gap = 8;
let x = 0;
let y = 0;
switch (placement()) {
case "top":
x = triggerRect.left + triggerRect.width / 2 - tooltipRect.width / 2;
y = triggerRect.top - tooltipRect.height - gap;
break;
case "bottom":
x = triggerRect.left + triggerRect.width / 2 - tooltipRect.width / 2;
y = triggerRect.bottom + gap;
break;
case "left":
x = triggerRect.left - tooltipRect.width - gap;
y = triggerRect.top + triggerRect.height / 2 - tooltipRect.height / 2;
break;
case "right":
x = triggerRect.right + gap;
y = triggerRect.top + triggerRect.height / 2 - tooltipRect.height / 2;
break;
}
// Keep tooltip within viewport
x = Math.max(8, Math.min(x, window.innerWidth - tooltipRect.width - 8));
y = Math.max(8, Math.min(y, window.innerHeight - tooltipRect.height - 8));
setPosition({ x, y });
};
const handleMouseEnter = () => {
if (local.disabled) return;
timeoutId = window.setTimeout(() => {
setShow(true);
requestAnimationFrame(calculatePosition);
}, delay());
};
const handleMouseLeave = () => {
if (timeoutId) {
clearTimeout(timeoutId);
}
setShow(false);
};
onCleanup(() => {
if (timeoutId) {
clearTimeout(timeoutId);
}
});
return (
<>
<span
ref={triggerRef}
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
onFocus={handleMouseEnter}
onBlur={handleMouseLeave}
>
{local.children}
</span>
<Show when={show() && !local.disabled}>
<Portal>
<div
ref={tooltipRef}
class="fixed z-50 px-3 py-2 text-xs font-medium text-text-primary bg-surface-3 border border-border-strong rounded-lg shadow-lg animate-fade-in pointer-events-none"
style={{
left: `${position().x}px`,
top: `${position().y}px`,
}}
role="tooltip"
>
{local.content}
</div>
</Portal>
</Show>
</>
);
}
@@ -0,0 +1,79 @@
import { type JSX, For, createSignal, createEffect, onMount, onCleanup } from "solid-js";
interface VirtualListProps<T> {
items: T[];
itemHeight: number;
height: number;
overscan?: number;
renderItem: (item: T, index: number) => JSX.Element;
class?: string;
}
/**
* High-performance virtual list component for rendering large datasets
* Only renders visible items + overscan buffer
*/
export function VirtualList<T>(props: VirtualListProps<T>) {
const [scrollTop, setScrollTop] = createSignal(0);
let containerRef: HTMLDivElement | undefined;
const overscan = () => props.overscan ?? 3;
const totalHeight = () => props.items.length * props.itemHeight;
// Calculate visible range
const visibleRange = () => {
const start = Math.floor(scrollTop() / props.itemHeight);
const end = Math.ceil((scrollTop() + props.height) / props.itemHeight);
return {
start: Math.max(0, start - overscan()),
end: Math.min(props.items.length, end + overscan()),
};
};
const visibleItems = () => {
const range = visibleRange();
return props.items.slice(range.start, range.end).map((item, i) => ({
item,
index: range.start + i,
}));
};
const handleScroll = (e: Event) => {
const target = e.target as HTMLDivElement;
setScrollTop(target.scrollTop);
};
onMount(() => {
containerRef?.addEventListener("scroll", handleScroll, { passive: true });
});
onCleanup(() => {
containerRef?.removeEventListener("scroll", handleScroll);
});
return (
<div
ref={containerRef}
class={`overflow-auto ${props.class ?? ""}`}
style={{ height: `${props.height}px` }}
>
<div style={{ height: `${totalHeight()}px`, position: "relative" }}>
<For each={visibleItems()}>
{({ item, index }) => (
<div
style={{
position: "absolute",
top: `${index * props.itemHeight}px`,
height: `${props.itemHeight}px`,
width: "100%",
}}
>
{props.renderItem(item, index)}
</div>
)}
</For>
</div>
</div>
);
}
+23
View File
@@ -0,0 +1,23 @@
// Components barrel export
export { Badge, StatusBadge } from "./Badge";
export { Button } from "./Button";
export { Card, CardHeader, CardContent, CardFooter, StatCard } from "./Card";
export { CommandPalette } from "./CommandPalette";
export { CommandPaletteEnhanced } from "./CommandPaletteEnhanced";
export { DataTable } from "./DataTable";
export { Dropdown } from "./Dropdown";
export { Input, Textarea, Select, FileInput } from "./Input";
export { Layout, Sidebar, Header, PageHeader, EmptyState } from "./Layout";
export { Logo, LogoIcon } from "./Logo";
export { Message, Loading, Skeleton, SkeletonCard } from "./Message";
export { Modal, ModalFooter } from "./Modal";
export { NotificationCenter, addNotification, removeNotification, clearNotifications, notify } from "./NotificationCenter";
export { OnboardingModal } from "./OnboardingModal";
export { Progress, CircularProgress, Spinner } from "./Progress";
export { ProjectDashboard } from "./ProjectDashboard";
export { Table, DataTable as DataTableOld, Pagination } from "./Table";
export { Tabs, TabPanel } from "./Tabs";
export { ToastContainer, toast } from "./Toast";
export { Tooltip } from "./Tooltip";
export { VirtualList } from "./VirtualList";
export { NetworkError, DemoBanner } from "./NetworkError";
@@ -0,0 +1,68 @@
import { createSignal, onMount, onCleanup, Accessor } from "solid-js";
interface UseIntersectionObserverOptions extends IntersectionObserverInit {
freezeOnceVisible?: boolean;
}
/**
* Hook to observe element visibility using Intersection Observer API
* Useful for lazy loading, infinite scroll, and animations on scroll
*/
export function useIntersectionObserver(
elementRef: Accessor<HTMLElement | undefined>,
options: UseIntersectionObserverOptions = {}
): Accessor<IntersectionObserverEntry | undefined> {
const [entry, setEntry] = createSignal<IntersectionObserverEntry>();
const { freezeOnceVisible = false, ...observerOptions } = options;
onMount(() => {
const element = elementRef();
if (!element) return;
const observer = new IntersectionObserver(
([entry]) => {
setEntry(entry);
if (freezeOnceVisible && entry.isIntersecting) {
observer.disconnect();
}
},
observerOptions
);
observer.observe(element);
onCleanup(() => {
observer.disconnect();
});
});
return entry;
}
/**
* Hook for lazy loading images
*/
export function useLazyImage(
imageRef: Accessor<HTMLImageElement | undefined>,
src: string
): Accessor<boolean> {
const [loaded, setLoaded] = createSignal(false);
const entry = useIntersectionObserver(imageRef, {
threshold: 0.1,
freezeOnceVisible: true,
});
createSignal(() => {
const img = imageRef();
const isVisible = entry()?.isIntersecting;
if (img && isVisible && !loaded()) {
img.src = src;
img.onload = () => setLoaded(true);
}
});
return loaded;
}
@@ -0,0 +1,86 @@
import { onCleanup, onMount } from "solid-js";
export interface KeyboardShortcut {
key: string;
ctrl?: boolean;
shift?: boolean;
alt?: boolean;
meta?: boolean;
description?: string;
action: (event: KeyboardEvent) => void;
preventDefault?: boolean;
}
/**
* Hook for registering keyboard shortcuts
*/
export function useKeyboardShortcuts(shortcuts: KeyboardShortcut[]) {
const handleKeyDown = (event: KeyboardEvent) => {
for (const shortcut of shortcuts) {
const keyMatches = event.key.toLowerCase() === shortcut.key.toLowerCase();
const ctrlMatches = shortcut.ctrl === undefined || event.ctrlKey === shortcut.ctrl;
const shiftMatches = shortcut.shift === undefined || event.shiftKey === shortcut.shift;
const altMatches = shortcut.alt === undefined || event.altKey === shortcut.alt;
const metaMatches = shortcut.meta === undefined || event.metaKey === shortcut.meta;
if (keyMatches && ctrlMatches && shiftMatches && altMatches && metaMatches) {
if (shortcut.preventDefault !== false) {
event.preventDefault();
}
shortcut.action(event);
break;
}
}
};
onMount(() => {
document.addEventListener("keydown", handleKeyDown);
});
onCleanup(() => {
document.removeEventListener("keydown", handleKeyDown);
});
}
/**
* Format shortcut for display
*/
export function formatShortcut(shortcut: KeyboardShortcut): string {
const parts: string[] = [];
const isMac = navigator.platform.toUpperCase().indexOf("MAC") >= 0;
if (shortcut.ctrl) parts.push(isMac ? "⌃" : "Ctrl");
if (shortcut.alt) parts.push(isMac ? "⌥" : "Alt");
if (shortcut.shift) parts.push(isMac ? "⇧" : "Shift");
if (shortcut.meta) parts.push(isMac ? "⌘" : "Win");
parts.push(shortcut.key.toUpperCase());
return parts.join(isMac ? "" : "+");
}
/**
* Common keyboard shortcuts
*/
export const commonShortcuts = {
save: { key: "s", ctrl: true, meta: true, description: "Save" },
copy: { key: "c", ctrl: true, meta: true, description: "Copy" },
paste: { key: "v", ctrl: true, meta: true, description: "Paste" },
cut: { key: "x", ctrl: true, meta: true, description: "Cut" },
undo: { key: "z", ctrl: true, meta: true, description: "Undo" },
redo: { key: "z", ctrl: true, meta: true, shift: true, description: "Redo" },
selectAll: { key: "a", ctrl: true, meta: true, description: "Select All" },
find: { key: "f", ctrl: true, meta: true, description: "Find" },
newTab: { key: "t", ctrl: true, meta: true, description: "New Tab" },
closeTab: { key: "w", ctrl: true, meta: true, description: "Close Tab" },
refresh: { key: "r", ctrl: true, meta: true, description: "Refresh" },
commandPalette: { key: "k", ctrl: true, meta: true, description: "Command Palette" },
settings: { key: ",", ctrl: true, meta: true, description: "Settings" },
escape: { key: "Escape", description: "Cancel/Close" },
enter: { key: "Enter", description: "Confirm/Submit" },
arrowUp: { key: "ArrowUp", description: "Move Up" },
arrowDown: { key: "ArrowDown", description: "Move Down" },
arrowLeft: { key: "ArrowLeft", description: "Move Left" },
arrowRight: { key: "ArrowRight", description: "Move Right" },
};
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,23 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { OpenAPI } from '@primora/api-client';
describe('API Configuration', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('should configure base URL from environment', () => {
expect(OpenAPI.BASE).toBeDefined();
expect(OpenAPI.BASE).toContain('/api/v1');
});
it('should include credentials in requests', () => {
expect(OpenAPI.CREDENTIALS).toBe('include');
expect(OpenAPI.WITH_CREDENTIALS).toBe(true);
});
it('should have token resolver configured', () => {
expect(OpenAPI.TOKEN).toBeDefined();
expect(typeof OpenAPI.TOKEN).toBe('function');
});
});
+9
View File
@@ -0,0 +1,9 @@
import { vi } from 'vitest';
// Mock environment variables
vi.stubEnv('VITE_API_BASE_URL', 'http://localhost/api/v1');
vi.stubEnv('VITE_AUTH_BASE_URL', 'http://localhost/auth');
vi.stubEnv('VITE_APP_URL', 'http://localhost');
// Mock fetch globally
global.fetch = vi.fn();
+12
View File
@@ -0,0 +1,12 @@
import { OpenAPI } from "@primora/api-client";
import { fetchApiToken } from "./auth-client";
OpenAPI.BASE = import.meta.env.VITE_API_BASE_URL ?? "/api/v1";
OpenAPI.CREDENTIALS = "include";
OpenAPI.WITH_CREDENTIALS = true;
OpenAPI.TOKEN = async () => (await fetchApiToken()) ?? "";
export function configureApiToken(tokenResolver?: () => Promise<string | undefined>) {
OpenAPI.TOKEN = tokenResolver ? async () => (await tokenResolver()) ?? "" : async () => (await fetchApiToken()) ?? "";
}
+28
View File
@@ -0,0 +1,28 @@
import { createAuthClient } from "better-auth/solid";
// Use full URL for dev mode, relative path for production
const baseURL = import.meta.env.VITE_AUTH_BASE_URL ?? "http://localhost/auth";
export const authClient = createAuthClient({
baseURL,
});
export async function fetchApiToken() {
const response = await fetch(`${baseURL}/token`, {
credentials: "include",
headers: {
accept: "application/json",
},
});
if (response.status === 401 || response.status === 403) {
return undefined;
}
if (!response.ok) {
throw new Error(`Failed to fetch API token (${response.status})`);
}
const data = (await response.json()) as { token: string };
return data.token;
}
+570
View File
@@ -0,0 +1,570 @@
/**
* Demo Mode - Provides mock data for testing without backend
*/
import type {
OrganizationSummary,
ProjectSummary,
ProjectOverview,
OrganizationMember,
OrganizationInvitation,
ProjectMember,
ApiKey,
Bucket,
BucketObject,
AuditLog,
} from "@primora/api-client";
// Check if demo mode is enabled
export const isDemoMode = () => {
// Check environment variable first
if (import.meta.env.VITE_DEMO_MODE === 'true') {
return true;
}
// Check URL parameter
const params = new URLSearchParams(window.location.search);
if (params.get('demo') === 'true') {
return true;
}
// Check localStorage
return localStorage.getItem('primora_demo_mode') === 'true';
};
export const enableDemoMode = () => {
localStorage.setItem('primora_demo_mode', 'true');
window.location.search = '?demo=true';
};
export const disableDemoMode = () => {
localStorage.removeItem('primora_demo_mode');
window.location.href = window.location.pathname;
};
// Demo user session
export const demoSession = {
user: {
id: 'demo-user-1',
email: 'demo@primora.dev',
name: 'Demo User',
emailVerified: true,
image: null,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
},
session: {
id: 'demo-session-1',
userId: 'demo-user-1',
expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000).toISOString(),
token: 'demo-token',
ipAddress: '127.0.0.1',
userAgent: navigator.userAgent,
},
};
// Demo organizations
export const demoOrganizations: OrganizationSummary[] = [
{
id: 'org-1',
name: 'Acme Corporation',
slug: 'acme-corp',
membershipRole: 'owner',
projects: [
{
id: 'proj-1',
name: 'Production API',
slug: 'production-api',
description: 'Main production API service',
membershipRole: 'admin',
},
{
id: 'proj-2',
name: 'Mobile App',
slug: 'mobile-app',
description: 'iOS and Android mobile application',
membershipRole: 'developer',
},
],
},
{
id: 'org-2',
name: 'Demo Workspace',
slug: 'demo-workspace',
membershipRole: 'admin',
projects: [
{
id: 'proj-3',
name: 'Test Project',
slug: 'test-project',
description: 'Testing and development',
membershipRole: 'admin',
},
],
},
];
// Demo project overview
export const demoProjectOverview: ProjectOverview = {
member_count: 5,
active_api_key_count: 3,
bucket_count: 4,
object_count: 127,
object_bytes_total: 45678901,
pending_invitation_count: 2,
};
// Demo organization members
export const demoOrganizationMembers: OrganizationMember[] = [
{
user_id: 'demo-user-1',
name: 'Demo User',
email: 'demo@primora.dev',
role: 'owner',
joined_at: new Date(Date.now() - 90 * 24 * 60 * 60 * 1000).toISOString(),
},
{
user_id: 'user-2',
name: 'Alice Johnson',
email: 'alice@example.com',
role: 'admin',
joined_at: new Date(Date.now() - 60 * 24 * 60 * 60 * 1000).toISOString(),
},
{
user_id: 'user-3',
name: 'Bob Smith',
email: 'bob@example.com',
role: 'member',
joined_at: new Date(Date.now() - 30 * 24 * 60 * 60 * 1000).toISOString(),
},
];
// Demo invitations
export const demoInvitations: OrganizationInvitation[] = [
{
id: 'inv-1',
email: 'charlie@example.com',
orgRole: 'member',
projectId: null,
projectRole: null,
status: 'pending',
expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000).toISOString(),
createdAt: new Date(Date.now() - 2 * 24 * 60 * 60 * 1000).toISOString(),
},
{
id: 'inv-2',
email: 'diana@example.com',
orgRole: 'admin',
projectId: 'proj-1',
projectRole: 'developer',
status: 'pending',
expiresAt: new Date(Date.now() + 5 * 24 * 60 * 60 * 1000).toISOString(),
createdAt: new Date(Date.now() - 1 * 24 * 60 * 60 * 1000).toISOString(),
},
];
// Demo project members
export const demoProjectMembers: ProjectMember[] = [
{
user_id: 'demo-user-1',
name: 'Demo User',
email: 'demo@primora.dev',
role: 'admin',
joined_at: new Date(Date.now() - 90 * 24 * 60 * 60 * 1000).toISOString(),
},
{
user_id: 'user-2',
name: 'Alice Johnson',
email: 'alice@example.com',
role: 'developer',
joined_at: new Date(Date.now() - 60 * 24 * 60 * 60 * 1000).toISOString(),
},
{
user_id: 'user-3',
name: 'Bob Smith',
email: 'bob@example.com',
role: 'viewer',
joined_at: new Date(Date.now() - 30 * 24 * 60 * 60 * 1000).toISOString(),
},
];
// Demo API keys
export const demoApiKeys: ApiKey[] = [
{
id: 'key-1',
name: 'Production Key',
prefix: 'pk_live_',
revoked_at: null,
created_at: new Date(Date.now() - 60 * 24 * 60 * 60 * 1000).toISOString(),
},
{
id: 'key-2',
name: 'Development Key',
prefix: 'pk_test_',
revoked_at: null,
created_at: new Date(Date.now() - 30 * 24 * 60 * 60 * 1000).toISOString(),
},
{
id: 'key-3',
name: 'Old Key',
prefix: 'pk_old_',
revoked_at: new Date(Date.now() - 7 * 24 * 60 * 60 * 1000).toISOString(),
created_at: new Date(Date.now() - 120 * 24 * 60 * 60 * 1000).toISOString(),
},
];
// Demo buckets
export const demoBuckets: Bucket[] = [
{
id: 'bucket-1',
name: 'User Avatars',
slug: 'avatars',
visibility: 'public',
created_at: new Date(Date.now() - 90 * 24 * 60 * 60 * 1000).toISOString(),
updated_at: new Date(Date.now() - 10 * 24 * 60 * 60 * 1000).toISOString(),
},
{
id: 'bucket-2',
name: 'Documents',
slug: 'documents',
visibility: 'private',
created_at: new Date(Date.now() - 60 * 24 * 60 * 60 * 1000).toISOString(),
updated_at: new Date(Date.now() - 5 * 24 * 60 * 60 * 1000).toISOString(),
},
{
id: 'bucket-3',
name: 'Media Files',
slug: 'media',
visibility: 'public',
created_at: new Date(Date.now() - 45 * 24 * 60 * 60 * 1000).toISOString(),
updated_at: new Date(Date.now() - 2 * 24 * 60 * 60 * 1000).toISOString(),
},
];
// Demo objects
export const demoObjects: BucketObject[] = [
{
object_key: 'profile/user-123.jpg',
size_bytes: 245678,
content_type: 'image/jpeg',
created_at: new Date(Date.now() - 30 * 24 * 60 * 60 * 1000).toISOString(),
updated_at: new Date(Date.now() - 30 * 24 * 60 * 60 * 1000).toISOString(),
},
{
object_key: 'profile/user-456.png',
size_bytes: 189234,
content_type: 'image/png',
created_at: new Date(Date.now() - 25 * 24 * 60 * 60 * 1000).toISOString(),
updated_at: new Date(Date.now() - 25 * 24 * 60 * 60 * 1000).toISOString(),
},
{
object_key: 'documents/report-2024.pdf',
size_bytes: 1234567,
content_type: 'application/pdf',
created_at: new Date(Date.now() - 20 * 24 * 60 * 60 * 1000).toISOString(),
updated_at: new Date(Date.now() - 20 * 24 * 60 * 60 * 1000).toISOString(),
},
{
object_key: 'config/settings.json',
size_bytes: 4567,
content_type: 'application/json',
created_at: new Date(Date.now() - 15 * 24 * 60 * 60 * 1000).toISOString(),
updated_at: new Date(Date.now() - 5 * 24 * 60 * 60 * 1000).toISOString(),
},
];
// Demo audit logs
export const demoAuditLogs: AuditLog[] = [
{
id: 'log-1',
action: 'object.created',
resource_type: 'object',
resource_id: 'profile/user-123.jpg',
actor_id: 'demo-user-1',
actor_name: 'Demo User',
request_id: 'req-abc123',
metadata: { bucket_id: 'bucket-1', size_bytes: 245678 },
created_at: new Date(Date.now() - 2 * 60 * 60 * 1000).toISOString(),
},
{
id: 'log-2',
action: 'api_key.created',
resource_type: 'api_key',
resource_id: 'key-2',
actor_id: 'user-2',
actor_name: 'Alice Johnson',
request_id: 'req-def456',
metadata: { key_name: 'Development Key' },
created_at: new Date(Date.now() - 5 * 60 * 60 * 1000).toISOString(),
},
{
id: 'log-3',
action: 'member.invited',
resource_type: 'invitation',
resource_id: 'inv-1',
actor_id: 'demo-user-1',
actor_name: 'Demo User',
request_id: 'req-ghi789',
metadata: { email: 'charlie@example.com', role: 'member' },
created_at: new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString(),
},
{
id: 'log-4',
action: 'bucket.created',
resource_type: 'bucket',
resource_id: 'bucket-3',
actor_id: 'demo-user-1',
actor_name: 'Demo User',
request_id: 'req-jkl012',
metadata: { bucket_name: 'Media Files', visibility: 'public' },
created_at: new Date(Date.now() - 48 * 60 * 60 * 1000).toISOString(),
},
];
// Demo service - simulates API calls with delays
export class DemoService {
private delay(ms: number = 300) {
return new Promise(resolve => setTimeout(resolve, ms));
}
async getMe() {
await this.delay();
return {
user: demoSession.user,
organizations: demoOrganizations,
};
}
async getProjectOverview() {
await this.delay();
return demoProjectOverview;
}
async listOrganizationMembers() {
await this.delay();
return { items: demoOrganizationMembers };
}
async listOrganizationInvitations() {
await this.delay();
return { items: demoInvitations };
}
async listProjectMembers() {
await this.delay();
return { items: demoProjectMembers };
}
async listApiKeys() {
await this.delay();
return { items: demoApiKeys };
}
async listBuckets() {
await this.delay();
return { items: demoBuckets };
}
async listBucketObjects() {
await this.delay();
return {
items: demoObjects,
total: demoObjects.length,
has_more: false,
};
}
async listAuditLogs() {
await this.delay();
return {
items: demoAuditLogs,
total: demoAuditLogs.length,
has_more: false,
};
}
async listProjects() {
await this.delay();
return { items: demoOrganizations[0].projects };
}
// Mock mutation methods
async createProject(data: any) {
await this.delay();
return {
id: `proj-${Date.now()}`,
...data,
};
}
async updateProject(data: any) {
await this.delay();
return data;
}
async deleteProject() {
await this.delay();
return { success: true };
}
async createInvitation(data: any) {
await this.delay();
return {
id: `inv-${Date.now()}`,
...data,
status: 'pending',
expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000).toISOString(),
createdAt: new Date().toISOString(),
};
}
async createApiKey(data: any) {
await this.delay();
return {
id: `key-${Date.now()}`,
prefix: 'pk_demo_',
secret: 'sk_demo_1234567890abcdefghijklmnopqrstuvwxyz',
...data,
revoked_at: null,
created_at: new Date().toISOString(),
};
}
async createBucket(data: any) {
await this.delay();
return {
id: `bucket-${Date.now()}`,
...data,
created_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
};
}
async uploadBucketObject(data: any) {
await this.delay();
return {
object_key: data.objectKey,
size_bytes: data.file?.size || 0,
content_type: data.file?.type || 'application/octet-stream',
created_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
};
}
// Other mock methods that just return success
async updateOrganization(data: any) {
await this.delay();
return {
id: data.organizationId || 'org-1',
name: data.requestBody?.name || 'Updated Org',
slug: data.requestBody?.slug || 'updated-org',
membershipRole: 'owner',
projects: [],
};
}
async deleteOrganization() {
await this.delay();
return { success: true };
}
async createOrganization(data: any) {
await this.delay();
return {
id: `org-${Date.now()}`,
name: data.requestBody?.name || 'New Organization',
slug: data.requestBody?.slug || 'new-org',
membershipRole: 'owner',
projects: [],
};
}
async revokeInvitation() {
await this.delay();
return { success: true };
}
async acceptInvitation() {
await this.delay();
return { success: true };
}
async updateOrganizationMemberRole() {
await this.delay();
return { success: true };
}
async removeOrganizationMember() {
await this.delay();
return { success: true };
}
async updateProjectMemberRole() {
await this.delay();
return { success: true };
}
async removeProjectMember() {
await this.delay();
return { success: true };
}
async revokeApiKey() {
await this.delay();
return { success: true };
}
async updateBucket(data: any) {
await this.delay();
return {
id: data.bucketId || 'bucket-1',
...data.requestBody,
created_at: new Date(Date.now() - 90 * 24 * 60 * 60 * 1000).toISOString(),
updated_at: new Date().toISOString(),
};
}
async deleteBucket() {
await this.delay();
return { success: true };
}
async updateBucketObject() {
await this.delay();
return { success: true };
}
async copyBucketObject() {
await this.delay();
return { success: true };
}
async deleteBucketObject() {
await this.delay();
return { success: true };
}
async downloadBucketObject() {
await this.delay();
// Return a mock blob
return new Blob(['Demo file content'], { type: 'text/plain' });
}
async bootstrapPlatform(data: any) {
await this.delay();
return {
organization: {
id: 'org-bootstrap',
name: data.requestBody?.organizationName || 'Bootstrap Org',
slug: data.requestBody?.organizationSlug || 'bootstrap-org',
},
project: {
id: 'proj-bootstrap',
name: data.requestBody?.projectName || 'Bootstrap Project',
slug: data.requestBody?.projectSlug || 'bootstrap-project',
},
};
}
}
export const demoService = new DemoService();
+14
View File
@@ -0,0 +1,14 @@
/* @refresh reload */
import { render } from "solid-js/web";
import "./index.css";
import { ShowcasePage } from "./ShowcasePage";
const root = document.getElementById("root");
if (import.meta.env.DEV && !(root instanceof HTMLElement)) {
throw new Error(
"Root element not found. Did you forget to add it to your index.html? Or maybe the id attribute got misspelled?",
);
}
render(() => <ShowcasePage />, root!);
+6
View File
@@ -0,0 +1,6 @@
import { render } from "solid-js/web";
import App from "./App";
import "./index.css";
render(() => <App />, document.getElementById("root")!);
+298
View File
@@ -0,0 +1,298 @@
import { Show, For } from "solid-js";
import { Button, Card, Input, Select, Badge, Table, EmptyState } from "../components";
import type { AuditLog } from "@primora/api-client";
interface AuditPageProps {
auditLogs?: AuditLog[];
auditSearch: string;
auditAction: string;
auditOffset: number;
auditPage?: { items: AuditLog[]; total: number; limit: number; offset: number };
onAuditSearchChange: (value: string) => void;
onAuditActionChange: (value: string) => void;
onAuditPageChange: (offset: number) => void;
onRefreshAudit: () => void;
formatDate: (date?: string | null) => string;
}
export function AuditPage(props: AuditPageProps) {
const getActionBadgeVariant = (action: string) => {
if (action.includes('create')) return 'success';
if (action.includes('delete')) return 'danger';
if (action.includes('update')) return 'primary';
return 'secondary';
};
const getActionIcon = (action: string) => {
if (action.includes('create')) {
return (
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4" />
</svg>
);
}
if (action.includes('delete')) {
return (
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
</svg>
);
}
if (action.includes('update')) {
return (
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
</svg>
);
}
return (
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
);
};
const formatResourceType = (type: string) => {
return type.split('_').map(word => word.charAt(0).toUpperCase() + word.slice(1)).join(' ');
};
return (
<div class="space-y-6">
{/* Header */}
<div class="flex items-center justify-between">
<div>
<h1 class="text-3xl font-bold text-gray-900">Audit Logs</h1>
<p class="text-gray-600 mt-1">Track all activities and changes in your project</p>
</div>
<Button variant="secondary" onClick={props.onRefreshAudit}>
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
</svg>
Refresh
</Button>
</div>
{/* Filters */}
<Card class="p-4">
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<Input
placeholder="Search by resource, actor, or details..."
value={props.auditSearch}
onInput={(e) => props.onAuditSearchChange(e.currentTarget.value)}
/>
<Select
value={props.auditAction}
onChange={(e) => props.onAuditActionChange(e.currentTarget.value)}
>
<option value="">All Actions</option>
<option value="create">Create</option>
<option value="update">Update</option>
<option value="delete">Delete</option>
<option value="upload">Upload</option>
<option value="download">Download</option>
</Select>
</div>
</Card>
{/* Stats Cards */}
<div class="grid grid-cols-1 md:grid-cols-4 gap-4">
<Card class="p-4">
<div class="flex items-center gap-3">
<div class="w-10 h-10 bg-blue-100 rounded-lg flex items-center justify-center">
<svg class="w-5 h-5 text-blue-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" 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>
</div>
<div>
<p class="text-2xl font-bold text-gray-900">{props.auditPage?.total ?? 0}</p>
<p class="text-sm text-gray-600">Total Events</p>
</div>
</div>
</Card>
<Card class="p-4">
<div class="flex items-center gap-3">
<div class="w-10 h-10 bg-green-100 rounded-lg flex items-center justify-center">
<svg class="w-5 h-5 text-green-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4" />
</svg>
</div>
<div>
<p class="text-2xl font-bold text-gray-900">
{props.auditLogs?.filter(log => log.action.includes('create')).length ?? 0}
</p>
<p class="text-sm text-gray-600">Creates</p>
</div>
</div>
</Card>
<Card class="p-4">
<div class="flex items-center gap-3">
<div class="w-10 h-10 bg-yellow-100 rounded-lg flex items-center justify-center">
<svg class="w-5 h-5 text-yellow-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
</svg>
</div>
<div>
<p class="text-2xl font-bold text-gray-900">
{props.auditLogs?.filter(log => log.action.includes('update')).length ?? 0}
</p>
<p class="text-sm text-gray-600">Updates</p>
</div>
</div>
</Card>
<Card class="p-4">
<div class="flex items-center gap-3">
<div class="w-10 h-10 bg-red-100 rounded-lg flex items-center justify-center">
<svg class="w-5 h-5 text-red-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
</svg>
</div>
<div>
<p class="text-2xl font-bold text-gray-900">
{props.auditLogs?.filter(log => log.action.includes('delete')).length ?? 0}
</p>
<p class="text-sm text-gray-600">Deletes</p>
</div>
</div>
</Card>
</div>
{/* Audit Logs Table */}
<Card>
<Show
when={(props.auditLogs?.length ?? 0) > 0}
fallback={
<div class="p-12">
<EmptyState
icon={
<svg class="w-16 h-16" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="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 2m-3 7h3m-3 4h3m-6-4h.01M9 16h.01" />
</svg>
}
title="No audit logs"
description={props.auditSearch || props.auditAction ? "No logs match your filters" : "Activity will appear here as you use the platform"}
/>
</div>
}
>
<div class="overflow-x-auto">
<Table>
<thead>
<tr>
<th>Timestamp</th>
<th>Action</th>
<th>Resource</th>
<th>Actor</th>
<th>Details</th>
</tr>
</thead>
<tbody>
<For each={props.auditLogs}>
{(log) => (
<tr class="hover:bg-gray-50">
<td class="text-sm text-gray-600 whitespace-nowrap">
{props.formatDate(log.created_at)}
</td>
<td>
<div class="flex items-center gap-2">
<Badge variant={getActionBadgeVariant(log.action)} class="flex items-center gap-1">
{getActionIcon(log.action)}
{log.action}
</Badge>
</div>
</td>
<td>
<div>
<p class="font-medium text-gray-900">{formatResourceType(log.resource_type)}</p>
<Show when={log.resource_id}>
<code class="text-xs text-gray-500">{log.resource_id}</code>
</Show>
</div>
</td>
<td>
<div class="flex items-center gap-2">
<div class="w-8 h-8 rounded-full bg-gradient-to-br from-purple-500 to-pink-500 flex items-center justify-center text-white text-xs font-semibold">
{log.actor_name?.charAt(0).toUpperCase() ?? '?'}
</div>
<div>
<p class="text-sm font-medium text-gray-900">{log.actor_name}</p>
<p class="text-xs text-gray-500">{log.actor_email}</p>
</div>
</div>
</td>
<td>
<Show when={log.details}>
<details class="text-sm">
<summary class="cursor-pointer text-blue-600 hover:text-blue-700">
View details
</summary>
<pre class="mt-2 p-2 bg-gray-50 rounded text-xs overflow-x-auto">
{JSON.stringify(log.details, null, 2)}
</pre>
</details>
</Show>
</td>
</tr>
)}
</For>
</tbody>
</Table>
</div>
{/* Pagination */}
<Show when={props.auditPage && props.auditPage.total > props.auditPage.limit}>
<div class="p-4 border-t border-gray-200 flex items-center justify-between">
<p class="text-sm text-gray-600">
Showing {props.auditPage!.offset + 1} to {Math.min(props.auditPage!.offset + props.auditPage!.limit, props.auditPage!.total)} of {props.auditPage!.total}
</p>
<div class="flex gap-2">
<Button
variant="ghost"
size="sm"
disabled={props.auditPage!.offset === 0}
onClick={() => props.onAuditPageChange(Math.max(0, props.auditPage!.offset - props.auditPage!.limit))}
>
Previous
</Button>
<Button
variant="ghost"
size="sm"
disabled={props.auditPage!.offset + props.auditPage!.limit >= props.auditPage!.total}
onClick={() => props.onAuditPageChange(props.auditPage!.offset + props.auditPage!.limit)}
>
Next
</Button>
</div>
</div>
</Show>
</Show>
</Card>
{/* Export Section */}
<Card class="p-6 bg-gradient-to-r from-purple-50 to-blue-50 border-purple-200">
<div class="flex items-center justify-between">
<div>
<h3 class="text-lg font-semibold mb-1">Export Audit Logs</h3>
<p class="text-sm text-gray-600">Download audit logs for compliance and analysis</p>
</div>
<div class="flex gap-2">
<Button variant="outline">
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 10v6m0 0l-3-3m3 3l3-3m2 8H7a2 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>
Export CSV
</Button>
<Button variant="outline">
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 10v6m0 0l-3-3m3 3l3-3m2 8H7a2 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>
Export JSON
</Button>
</div>
</div>
</Card>
</div>
);
}
+286
View File
@@ -0,0 +1,286 @@
import { Show, For, createSignal, createMemo } from "solid-js";
import {
Button,
Card,
Input,
Textarea,
Badge,
EmptyState,
Message,
Modal,
Table,
DataTable
} from "../components";
import type { Collection, Document } from "@primora/api-client";
interface CollectionsPageProps {
collections: Collection[];
documents: Document[];
selectedCollectionID?: string;
collectionInput: { name: string; slug: string; description: string };
collectionMessage: string;
collectionPending: boolean;
documentPending: boolean;
canUpdate: boolean;
onCollectionInputChange: (field: string, value: string) => void;
onCreateCollection: (e: SubmitEvent) => void;
onDeleteCollection: (id: string) => void;
onSelectCollection: (id: string) => void;
onCreateDocument: (data: any) => void;
onUpdateDocument: (id: string, data: any) => void;
onDeleteDocument: (id: string) => void;
formatDate: (date?: string | null) => string;
}
export function CollectionsPage(props: CollectionsPageProps) {
const [showCreateCollectionModal, setShowCreateCollectionModal] = createSignal(false);
const [showDocumentModal, setShowDocumentModal] = createSignal(false);
const [editingDocument, setEditingDocument] = createSignal<Document | null>(null);
const [documentData, setDocumentData] = createSignal("");
const activeCollection = createMemo(() =>
props.collections.find(c => c.id === props.selectedCollectionID)
);
const handleCreateCollection = (e: SubmitEvent) => {
props.onCreateCollection(e);
setShowCreateCollectionModal(false);
};
const handleOpenDocumentModal = (doc: Document | null = null) => {
setEditingDocument(doc);
setDocumentData(doc ? JSON.stringify(doc.data, null, 2) : "{\n \n}");
setShowDocumentModal(true);
};
const handleSaveDocument = (e: SubmitEvent) => {
e.preventDefault();
try {
const data = JSON.parse(documentData());
if (editingDocument()) {
props.onUpdateDocument(editingDocument()!.id, data);
} else {
props.onCreateDocument(data);
}
setShowDocumentModal(false);
} catch (err) {
alert("Invalid JSON data");
}
};
return (
<div class="flex flex-col md:flex-row gap-6 h-[calc(100vh-12rem)]">
{/* Sidebar: Collections List */}
<div class="w-full md:w-64 flex flex-col gap-4">
<div class="flex items-center justify-between">
<h2 class="text-xl font-bold">Collections</h2>
<Button size="sm" onClick={() => setShowCreateCollectionModal(true)}>
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4" />
</svg>
</Button>
</div>
<Card class="flex-1 overflow-y-auto p-2">
<Show
when={props.collections.length > 0}
fallback={<p class="text-sm text-gray-500 p-4 text-center">No collections</p>}
>
<div class="space-y-1">
<For each={props.collections}>
{(collection) => (
<button
onClick={() => props.onSelectCollection(collection.id)}
class={`w-full text-left px-3 py-2 rounded-md text-sm transition-colors ${
props.selectedCollectionID === collection.id
? 'bg-blue-50 text-blue-700 font-medium'
: 'text-gray-600 hover:bg-gray-50'
}`}
>
<div class="flex items-center justify-between">
<span>{collection.name}</span>
<Show when={props.selectedCollectionID === collection.id}>
<div class="w-1.5 h-1.5 rounded-full bg-blue-600" />
</Show>
</div>
</button>
)}
</For>
</div>
</Show>
</Card>
</div>
{/* Main Content: Documents Table */}
<div class="flex-1 flex flex-col gap-4 min-w-0">
<Show
when={activeCollection()}
fallback={
<Card class="flex-1 flex items-center justify-center p-8">
<EmptyState
title="No collection selected"
description="Select a collection from the sidebar to view its documents"
icon={
<svg class="w-12 h-12 text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M4 7v10c0 2.21 3.582 4 8 4s8-1.79 8-4V7M4 7c0 2.21 3.582 4 8 4s8-1.79 8-4M4 7c0-2.21 3.582-4 8-4s8 1.79 8 4m0 5c0 2.21-3.582 4-8 4s-8-1.79-8-4" />
</svg>
}
/>
</Card>
}
>
<div class="flex items-center justify-between">
<div>
<h2 class="text-2xl font-bold text-gray-900">{activeCollection()?.name}</h2>
<p class="text-sm text-gray-500">{activeCollection()?.slug}</p>
</div>
<div class="flex gap-2">
<Button variant="ghost" size="sm" onClick={() => props.onDeleteCollection(activeCollection()!.id)}>
Delete Collection
</Button>
<Button onClick={() => handleOpenDocumentModal()}>
Add Document
</Button>
</div>
</div>
<Card class="flex-1 overflow-hidden flex flex-col">
<Show
when={props.documents.length > 0}
fallback={
<div class="flex-1 flex items-center justify-center p-8">
<EmptyState
title="No documents yet"
description="This collection is empty. Add your first document to get started."
action={<Button onClick={() => handleOpenDocumentModal()}>Add Document</Button>}
/>
</div>
}
>
<div class="overflow-auto">
<Table>
<thead>
<tr>
<th class="w-1/3">ID</th>
<th>Data</th>
<th class="w-48">Created At</th>
<th class="w-24 text-right">Actions</th>
</tr>
</thead>
<tbody>
<For each={props.documents}>
{(doc) => (
<tr class="hover:bg-gray-50">
<td class="font-mono text-xs text-gray-500">{doc.id}</td>
<td>
<div class="max-w-md truncate text-sm">
{JSON.stringify(doc.data)}
</div>
</td>
<td class="text-sm text-gray-500">{props.formatDate(doc.created_at)}</td>
<td class="text-right">
<div class="flex justify-end gap-1">
<button
onClick={() => handleOpenDocumentModal(doc)}
class="p-1 text-gray-400 hover:text-blue-600 transition-colors"
>
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15.232 5.232l3.536 3.536m-2.036-5.036a2.5 2.5 0 113.536 3.536L6.5 21.036H3v-3.572L16.732 3.732z" />
</svg>
</button>
<button
onClick={() => props.onDeleteDocument(doc.id)}
class="p-1 text-gray-400 hover:text-red-600 transition-colors"
>
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
</svg>
</button>
</div>
</td>
</tr>
)}
</For>
</tbody>
</Table>
</div>
</Show>
</Card>
</Show>
</div>
{/* Create Collection Modal */}
<Modal
open={showCreateCollectionModal()}
onClose={() => setShowCreateCollectionModal(false)}
title="Create New Collection"
>
<form class="space-y-4" onSubmit={handleCreateCollection}>
<Input
label="Collection Name"
placeholder="Users, Products, etc."
value={props.collectionInput.name}
onInput={(e) => {
const name = e.currentTarget.value;
props.onCollectionInputChange('name', name);
const slug = name.toLowerCase().trim().replace(/[^a-z0-9\s-]/g, '').replace(/\s+/g, '-').replace(/-+/g, '-');
props.onCollectionInputChange('slug', slug);
}}
required
/>
<Input
label="Collection Slug"
placeholder="users"
value={props.collectionInput.slug}
onInput={(e) => props.onCollectionInputChange('slug', e.currentTarget.value)}
required
/>
<Textarea
label="Description"
placeholder="What's in this collection?"
value={props.collectionInput.description}
onInput={(e) => props.onCollectionInputChange('description', e.currentTarget.value)}
rows={3}
/>
<div class="flex gap-3 justify-end pt-4">
<Button type="button" variant="ghost" onClick={() => setShowCreateCollectionModal(false)}>
Cancel
</Button>
<Button type="submit" variant="primary" disabled={props.collectionPending}>
{props.collectionPending ? "Creating..." : "Create Collection"}
</Button>
</div>
</form>
</Modal>
{/* Document Editor Modal */}
<Modal
open={showDocumentModal()}
onClose={() => setShowDocumentModal(false)}
title={editingDocument() ? "Edit Document" : "New Document"}
>
<form class="space-y-4" onSubmit={handleSaveDocument}>
<div class="space-y-2">
<label class="block text-sm font-medium text-gray-700">JSON Data</label>
<div class="font-mono text-sm border rounded-md overflow-hidden bg-gray-50">
<textarea
value={documentData()}
onInput={(e) => setDocumentData(e.currentTarget.value)}
class="w-full h-64 p-4 bg-transparent outline-none focus:ring-2 focus:ring-blue-500"
spellcheck={false}
/>
</div>
</div>
<div class="flex gap-3 justify-end pt-4">
<Button type="button" variant="ghost" onClick={() => setShowDocumentModal(false)}>
Cancel
</Button>
<Button type="submit" variant="primary" disabled={props.documentPending}>
{props.documentPending ? "Saving..." : "Save Document"}
</Button>
</div>
</form>
</Modal>
</div>
);
}
@@ -0,0 +1,223 @@
import { For } from "solid-js";
import { Logo } from "../components/Logo";
import { Button } from "../components/Button";
import { Card, CardHeader, StatCard } from "../components/Card";
import { Input, Textarea, Select } from "../components/Input";
import { Badge, StatusBadge } from "../components/Badge";
import { Message, Loading, Skeleton } from "../components/Message";
import { Table } from "../components/Table";
export function EnhancementsShowcase() {
const sampleData = [
{ id: 1, name: "Project Alpha", status: "active", users: 42 },
{ id: 2, name: "Project Beta", status: "pending", users: 18 },
{ id: 3, name: "Project Gamma", status: "completed", users: 156 },
];
const columns = [
{ key: "name", header: "Project Name" },
{ key: "status", header: "Status", render: (val: string) => <StatusBadge status={val as any} /> },
{ key: "users", header: "Users", align: "right" as const },
];
return (
<div class="min-h-screen bg-bg-main p-8">
<div class="max-w-7xl mx-auto space-y-12">
{/* Header */}
<div class="text-center space-y-4 animate-fade-in">
<div class="flex justify-center mb-6">
<Logo size="lg" animated />
</div>
<h1 class="text-5xl font-display font-extrabold">
Enhanced Design System
</h1>
<p class="text-lg text-text-secondary max-w-2xl mx-auto">
A distinctive, production-grade UI system with sophisticated micro-interactions,
refined typography, and purposeful animations.
</p>
</div>
{/* Typography Section */}
<section class="space-y-6 animate-slide-up stagger-1">
<div class="section-eyebrow">Typography</div>
<h2 class="section-title">Distinctive Font System</h2>
<div class="grid grid-cols-1 md:grid-cols-3 gap-6">
<Card class="card-premium">
<h3 class="font-display text-2xl font-bold mb-2">Syne Display</h3>
<p class="text-text-secondary text-sm">
Bold, geometric display font for headings and brand elements
</p>
</Card>
<Card class="card-premium">
<h3 class="font-sans text-2xl font-semibold mb-2">DM Sans Body</h3>
<p class="text-text-secondary text-sm">
Clean, readable sans-serif for body text and UI elements
</p>
</Card>
<Card class="card-premium">
<h3 class="font-mono text-xl font-medium mb-2">JetBrains Mono</h3>
<p class="text-text-secondary text-sm">
Professional monospace for code and technical content
</p>
</Card>
</div>
</section>
{/* Buttons Section */}
<section class="space-y-6 animate-slide-up stagger-2">
<div class="section-eyebrow">Interactive Elements</div>
<h2 class="section-title">Enhanced Buttons</h2>
<div class="flex flex-wrap gap-4">
<Button variant="primary" class="btn-glow">
Primary Action
</Button>
<Button variant="secondary">
Secondary Action
</Button>
<Button variant="ghost">
Ghost Button
</Button>
<Button variant="danger">
Danger Action
</Button>
<Button variant="primary" loading>
Loading...
</Button>
</div>
</section>
{/* Cards Section */}
<section class="space-y-6 animate-slide-up stagger-3">
<div class="section-eyebrow">Data Display</div>
<h2 class="section-title">Stat Cards with Animations</h2>
<div class="grid grid-cols-1 md:grid-cols-4 gap-6">
<div class="stat-card-enhanced">
<p class="stat-label">Total Users</p>
<p class="stat-value-animated">2,847</p>
<p class="text-xs text-success mt-2"> 12.5% from last month</p>
</div>
<div class="stat-card-enhanced">
<p class="stat-label">Active Projects</p>
<p class="stat-value-animated">42</p>
<p class="text-xs text-accent mt-2"> Stable</p>
</div>
<div class="stat-card-enhanced">
<p class="stat-label">Storage Used</p>
<p class="stat-value-animated">156 GB</p>
<p class="text-xs text-warning mt-2"> 8.2% from last month</p>
</div>
<div class="stat-card-enhanced">
<p class="stat-label">API Calls</p>
<p class="stat-value-animated">1.2M</p>
<p class="text-xs text-success mt-2"> 24.1% from last month</p>
</div>
</div>
</section>
{/* Forms Section */}
<section class="space-y-6 animate-slide-up stagger-4">
<div class="section-eyebrow">Form Elements</div>
<h2 class="section-title">Enhanced Inputs</h2>
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<Card class="card-premium">
<div class="space-y-4">
<div class="input-enhanced">
<Input label="Project Name" placeholder="Enter project name..." />
</div>
<div class="input-enhanced">
<Select label="Project Type" options={[
{ value: "web", label: "Web Application" },
{ value: "mobile", label: "Mobile App" },
{ value: "api", label: "API Service" },
]} />
</div>
<div class="input-enhanced">
<Textarea label="Description" placeholder="Describe your project..." />
</div>
</div>
</Card>
<Card class="card-premium">
<CardHeader
eyebrow="Form States"
title="Interactive Feedback"
description="Hover and focus states with smooth animations"
/>
<div class="space-y-4 mt-4">
<Badge variant="primary" class="badge-animated">Active</Badge>
<Badge variant="success" class="badge-animated">Completed</Badge>
<Badge variant="warning" class="badge-animated">Pending</Badge>
<Badge variant="error" class="badge-animated">Error</Badge>
</div>
</Card>
</div>
</section>
{/* Messages Section */}
<section class="space-y-6 animate-slide-up stagger-5">
<div class="section-eyebrow">Feedback</div>
<h2 class="section-title">Messages & Alerts</h2>
<div class="space-y-4">
<Message variant="success" title="Success!">
Your project has been created successfully.
</Message>
<Message variant="info" title="Information">
New features are available in the latest update.
</Message>
<Message variant="warning" title="Warning">
Your storage is almost full. Consider upgrading your plan.
</Message>
<Message variant="error" title="Error">
Failed to connect to the server. Please try again.
</Message>
</div>
</section>
{/* Table Section */}
<section class="space-y-6 animate-slide-up stagger-6">
<div class="section-eyebrow">Data Tables</div>
<h2 class="section-title">Enhanced Table Component</h2>
<Card class="card-premium">
<div class="table-enhanced">
<Table
columns={columns}
data={sampleData}
rowKey={(row) => row.id}
/>
</div>
</Card>
</section>
{/* Loading States */}
<section class="space-y-6 animate-slide-up">
<div class="section-eyebrow">Loading States</div>
<h2 class="section-title">Skeleton & Spinners</h2>
<div class="grid grid-cols-1 md:grid-cols-3 gap-6">
<Card>
<Skeleton width="60%" height="20px" class="mb-3" />
<Skeleton width="100%" height="14px" class="mb-2" />
<Skeleton width="100%" height="14px" class="mb-2" />
<Skeleton width="80%" height="14px" />
</Card>
<Card class="flex items-center justify-center">
<Loading text="Loading data..." size="lg" />
</Card>
<Card class="flex items-center justify-center">
<div class="loading-dots">
<span></span>
<span></span>
<span></span>
</div>
</Card>
</div>
</section>
{/* Footer */}
<footer class="text-center py-12 border-t border-border">
<p class="text-text-muted text-sm">
PRIMORA Enhanced Design System Production-grade UI components
</p>
</footer>
</div>
</div>
);
}
+386
View File
@@ -0,0 +1,386 @@
import { Show, For, createSignal } from "solid-js";
import { Button, Card, Input, Select, Badge, Table, EmptyState, Message, Modal, Tabs, TabPanel } from "../components";
import type { OrganizationMember, OrganizationInvitation, ProjectMember } from "@primora/api-client";
interface MembersPageProps {
organizationMembers?: OrganizationMember[];
organizationInvitations?: OrganizationInvitation[];
projectMembers?: ProjectMember[];
invitationInput: {
email: string;
orgRole: string;
attachProject: boolean;
projectRole: string;
};
inviteMessage: string;
memberMessage: string;
invitationPending: boolean;
membersPending: boolean;
canManageMembers: boolean;
hasActiveProject: boolean;
onInvitationInputChange: (field: string, value: any) => void;
onSendInvitation: (e: SubmitEvent) => void;
onRevokeInvitation: (id: string) => void;
onRemoveMember: (id: string, type: 'org' | 'project') => void;
onUpdateMemberRole: (id: string, role: string, type: 'org' | 'project') => void;
}
export function MembersPage(props: MembersPageProps) {
const [showInviteModal, setShowInviteModal] = createSignal(false);
const [activeTab, setActiveTab] = createSignal<'organization' | 'project'>('organization');
const [searchQuery, setSearchQuery] = createSignal("");
const filteredOrgMembers = () => {
const query = searchQuery().toLowerCase();
if (!query || !props.organizationMembers) return props.organizationMembers || [];
return props.organizationMembers.filter(m =>
m.user_name?.toLowerCase().includes(query) ||
m.user_email?.toLowerCase().includes(query)
);
};
const filteredProjectMembers = () => {
const query = searchQuery().toLowerCase();
if (!query || !props.projectMembers) return props.projectMembers || [];
return props.projectMembers.filter(m =>
m.user_name?.toLowerCase().includes(query) ||
m.user_email?.toLowerCase().includes(query)
);
};
const handleInviteSubmit = (e: SubmitEvent) => {
props.onSendInvitation(e);
setShowInviteModal(false);
};
const getRoleBadgeVariant = (role: string) => {
switch (role) {
case 'owner': return 'primary';
case 'admin': return 'success';
default: return 'secondary';
}
};
return (
<div class="space-y-6">
{/* Header */}
<div class="flex items-center justify-between">
<div>
<h1 class="text-3xl font-bold text-gray-900">Team Members</h1>
<p class="text-gray-600 mt-1">Manage organization and project access</p>
</div>
<Show when={props.canManageMembers}>
<Button onClick={() => setShowInviteModal(true)}>
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="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>
Invite Member
</Button>
</Show>
</div>
{/* Tabs */}
<Tabs
tabs={[
{ id: 'organization', label: 'Organization Members' },
{ id: 'project', label: 'Project Members', disabled: !props.hasActiveProject },
]}
activeTab={activeTab()}
onChange={(id) => setActiveTab(id as 'organization' | 'project')}
/>
{/* Search Bar */}
<Card class="p-4">
<Input
placeholder="Search members by name or email..."
value={searchQuery()}
onInput={(e) => setSearchQuery(e.currentTarget.value)}
class="w-full"
/>
</Card>
{/* Organization Members Tab */}
<Show when={activeTab() === 'organization'}>
<div class="space-y-6">
{/* Pending Invitations */}
<Show when={(props.organizationInvitations?.length ?? 0) > 0}>
<Card class="p-6">
<h2 class="text-lg font-semibold mb-4 flex items-center gap-2">
<svg class="w-5 h-5 text-yellow-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
Pending Invitations
</h2>
<div class="space-y-3">
<For each={props.organizationInvitations}>
{(invitation) => (
<div class="flex items-center justify-between p-4 bg-yellow-50 border border-yellow-200 rounded-lg">
<div class="flex-1">
<p class="font-medium text-gray-900">{invitation.email}</p>
<div class="flex items-center gap-2 mt-1">
<Badge variant="secondary">{invitation.org_role}</Badge>
<Show when={invitation.project_id}>
<Badge variant="secondary">Project: {invitation.project_role}</Badge>
</Show>
<span class="text-xs text-gray-500">
Invited {new Date(invitation.created_at).toLocaleDateString()}
</span>
</div>
</div>
<Show when={props.canManageMembers}>
<Button
variant="ghost"
size="sm"
onClick={() => props.onRevokeInvitation(invitation.id)}
disabled={props.invitationPending}
>
Revoke
</Button>
</Show>
</div>
)}
</For>
</div>
</Card>
</Show>
{/* Members List */}
<Card>
<Show
when={filteredOrgMembers().length > 0}
fallback={
<div class="p-12">
<EmptyState
icon={
<svg class="w-16 h-16" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197M13 7a4 4 0 11-8 0 4 4 0 018 0z" />
</svg>
}
title="No members found"
description={searchQuery() ? "Try adjusting your search" : "Invite team members to collaborate"}
/>
</div>
}
>
<Table>
<thead>
<tr>
<th>Member</th>
<th>Role</th>
<th>Joined</th>
<th class="text-right">Actions</th>
</tr>
</thead>
<tbody>
<For each={filteredOrgMembers()}>
{(member) => (
<tr>
<td>
<div class="flex items-center gap-3">
<div class="w-10 h-10 rounded-full bg-gradient-to-br from-blue-500 to-purple-500 flex items-center justify-center text-white font-semibold">
{member.user_name?.charAt(0).toUpperCase() ?? '?'}
</div>
<div>
<p class="font-medium text-gray-900">{member.user_name}</p>
<p class="text-sm text-gray-600">{member.user_email}</p>
</div>
</div>
</td>
<td>
<Show
when={props.canManageMembers && member.role !== 'owner'}
fallback={<Badge variant={getRoleBadgeVariant(member.role)}>{member.role}</Badge>}
>
<Select
value={member.role}
onChange={(e) => props.onUpdateMemberRole(member.user_id, e.currentTarget.value, 'org')}
disabled={props.membersPending}
class="w-32"
>
<option value="member">Member</option>
<option value="admin">Admin</option>
<option value="owner">Owner</option>
</Select>
</Show>
</td>
<td class="text-sm text-gray-600">
{new Date(member.created_at).toLocaleDateString()}
</td>
<td class="text-right">
<Show when={props.canManageMembers && member.role !== 'owner'}>
<Button
variant="ghost"
size="sm"
onClick={() => props.onRemoveMember(member.user_id, 'org')}
disabled={props.membersPending}
>
Remove
</Button>
</Show>
</td>
</tr>
)}
</For>
</tbody>
</Table>
</Show>
</Card>
</div>
</Show>
{/* Project Members Tab */}
<Show when={activeTab() === 'project'}>
<Card>
<Show
when={filteredProjectMembers().length > 0}
fallback={
<div class="p-12">
<EmptyState
icon={
<svg class="w-16 h-16" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197M13 7a4 4 0 11-8 0 4 4 0 018 0z" />
</svg>
}
title="No project members"
description="Add members to this project to collaborate"
/>
</div>
}
>
<Table>
<thead>
<tr>
<th>Member</th>
<th>Role</th>
<th>Added</th>
<th class="text-right">Actions</th>
</tr>
</thead>
<tbody>
<For each={filteredProjectMembers()}>
{(member) => (
<tr>
<td>
<div class="flex items-center gap-3">
<div class="w-10 h-10 rounded-full bg-gradient-to-br from-green-500 to-teal-500 flex items-center justify-center text-white font-semibold">
{member.user_name?.charAt(0).toUpperCase() ?? '?'}
</div>
<div>
<p class="font-medium text-gray-900">{member.user_name}</p>
<p class="text-sm text-gray-600">{member.user_email}</p>
</div>
</div>
</td>
<td>
<Show
when={props.canManageMembers}
fallback={<Badge variant={getRoleBadgeVariant(member.role)}>{member.role}</Badge>}
>
<Select
value={member.role}
onChange={(e) => props.onUpdateMemberRole(member.user_id, e.currentTarget.value, 'project')}
disabled={props.membersPending}
class="w-32"
>
<option value="viewer">Viewer</option>
<option value="developer">Developer</option>
<option value="admin">Admin</option>
</Select>
</Show>
</td>
<td class="text-sm text-gray-600">
{new Date(member.created_at).toLocaleDateString()}
</td>
<td class="text-right">
<Show when={props.canManageMembers}>
<Button
variant="ghost"
size="sm"
onClick={() => props.onRemoveMember(member.user_id, 'project')}
disabled={props.membersPending}
>
Remove
</Button>
</Show>
</td>
</tr>
)}
</For>
</tbody>
</Table>
</Show>
</Card>
</Show>
<Show when={props.memberMessage}>
<Message variant="neutral">{props.memberMessage}</Message>
</Show>
{/* Invite Modal */}
<Modal open={showInviteModal()} onClose={() => setShowInviteModal(false)} title="Invite Team Member">
<form class="space-y-4" onSubmit={handleInviteSubmit}>
<Input
label="Email Address"
type="email"
placeholder="colleague@example.com"
value={props.invitationInput.email}
onInput={(e) => props.onInvitationInputChange('email', e.currentTarget.value)}
disabled={props.invitationPending}
required
/>
<Select
label="Organization Role"
value={props.invitationInput.orgRole}
onChange={(e) => props.onInvitationInputChange('orgRole', e.currentTarget.value)}
disabled={props.invitationPending}
>
<option value="member">Member</option>
<option value="admin">Admin</option>
</Select>
<Show when={props.hasActiveProject}>
<div class="flex items-center gap-2">
<input
type="checkbox"
id="attachProject"
checked={props.invitationInput.attachProject}
onChange={(e) => props.onInvitationInputChange('attachProject', e.currentTarget.checked)}
disabled={props.invitationPending}
class="rounded border-gray-300"
/>
<label for="attachProject" class="text-sm text-gray-700">
Add to current project
</label>
</div>
<Show when={props.invitationInput.attachProject}>
<Select
label="Project Role"
value={props.invitationInput.projectRole}
onChange={(e) => props.onInvitationInputChange('projectRole', e.currentTarget.value)}
disabled={props.invitationPending}
>
<option value="viewer">Viewer</option>
<option value="developer">Developer</option>
<option value="admin">Admin</option>
</Select>
</Show>
</Show>
<div class="flex gap-3 justify-end pt-4">
<Button type="button" variant="ghost" onClick={() => setShowInviteModal(false)}>
Cancel
</Button>
<Button type="submit" variant="primary" disabled={props.invitationPending}>
{props.invitationPending ? "Sending..." : "Send Invitation"}
</Button>
</div>
</form>
<Show when={props.inviteMessage}>
<Message variant="neutral" class="mt-4">{props.inviteMessage}</Message>
</Show>
</Modal>
</div>
);
}
+237
View File
@@ -0,0 +1,237 @@
import { Show, For, createSignal } from "solid-js";
import { Button, Card, Input, Textarea, Badge, EmptyState, Message, Modal } from "../components";
import type { ProjectSummary } from "@primora/api-client";
interface ProjectsPageProps {
projects: ProjectSummary[];
selectedProjectID?: string;
projectInput: { name: string; slug: string; description: string };
projectEditInput: { name: string; slug: string; description: string };
projectMessage: string;
projectPending: boolean;
canUpdateProject: boolean;
onProjectInputChange: (field: string, value: string) => void;
onProjectEditInputChange: (field: string, value: string) => void;
onCreateProject: (e: SubmitEvent) => void;
onUpdateProject: (e: SubmitEvent) => void;
onDeleteProject: () => void;
onSelectProject: (id: string) => void;
onNavigateToDashboard: () => void;
}
export function ProjectsPage(props: ProjectsPageProps) {
const [showCreateModal, setShowCreateModal] = createSignal(false);
const [searchQuery, setSearchQuery] = createSignal("");
const filteredProjects = () => {
const query = searchQuery().toLowerCase();
if (!query) return props.projects;
return props.projects.filter(p =>
p.name.toLowerCase().includes(query) ||
p.slug.toLowerCase().includes(query) ||
p.description?.toLowerCase().includes(query)
);
};
const handleCreateSubmit = (e: SubmitEvent) => {
props.onCreateProject(e);
setShowCreateModal(false);
};
return (
<div class="space-y-6">
{/* Header */}
<div class="flex items-center justify-between">
<div>
<h1 class="text-3xl font-bold text-gray-900">Projects</h1>
<p class="text-gray-600 mt-1">Manage your organization's projects</p>
</div>
<Button onClick={() => setShowCreateModal(true)}>
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4" />
</svg>
New Project
</Button>
</div>
{/* Search Bar */}
<Card class="p-4">
<Input
placeholder="Search projects by name, slug, or description..."
value={searchQuery()}
onInput={(e) => setSearchQuery(e.currentTarget.value)}
class="w-full"
/>
</Card>
{/* Projects Grid */}
<Show
when={filteredProjects().length > 0}
fallback={
<EmptyState
icon={
<svg class="w-16 h-16" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="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>
}
title="No projects found"
description={searchQuery() ? "Try adjusting your search" : "Create your first project to get started"}
action={
<Show when={!searchQuery()}>
<Button onClick={() => setShowCreateModal(true)}>Create Project</Button>
</Show>
}
/>
}
>
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
<For each={filteredProjects()}>
{(project) => (
<Card
class={`p-6 cursor-pointer transition-all hover:shadow-lg hover:border-blue-500 ${
props.selectedProjectID === project.id ? 'border-blue-500 bg-blue-50' : ''
}`}
onClick={() => {
props.onSelectProject(project.id);
props.onNavigateToDashboard();
}}
>
<div class="flex items-start justify-between mb-4">
<div class="flex-1">
<h3 class="text-lg font-semibold text-gray-900 mb-1">{project.name}</h3>
<Badge variant="secondary" class="text-xs">{project.slug}</Badge>
</div>
<Show when={project.membershipRole}>
<Badge variant={project.membershipRole === 'admin' ? 'primary' : 'secondary'}>
{project.membershipRole}
</Badge>
</Show>
</div>
<Show when={project.description}>
<p class="text-sm text-gray-600 mb-4 line-clamp-2">{project.description}</p>
</Show>
<div class="flex items-center justify-between pt-4 border-t border-gray-200">
<button
onClick={(e) => {
e.stopPropagation();
props.onSelectProject(project.id);
props.onNavigateToDashboard();
}}
class="text-sm text-blue-600 hover:text-blue-700 font-medium"
>
View Dashboard →
</button>
<Show when={project.membershipRole === 'admin'}>
<button
onClick={(e) => {
e.stopPropagation();
props.onSelectProject(project.id);
}}
class="text-sm text-gray-600 hover:text-gray-700"
>
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" 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 stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
</button>
</Show>
</div>
</Card>
)}
</For>
</div>
</Show>
{/* Selected Project Settings */}
<Show when={props.selectedProjectID && props.canUpdateProject}>
<Card class="p-6">
<h2 class="text-xl font-semibold mb-4">Project Settings</h2>
<form class="space-y-4" onSubmit={props.onUpdateProject}>
<Input
label="Project Name"
placeholder="Enter project name"
value={props.projectEditInput.name}
onInput={(e) => props.onProjectEditInputChange('name', e.currentTarget.value)}
disabled={props.projectPending}
/>
<Input
label="Project Slug"
placeholder="project-slug"
value={props.projectEditInput.slug}
onInput={(e) => props.onProjectEditInputChange('slug', e.currentTarget.value)}
disabled={props.projectPending}
/>
<Textarea
label="Description"
placeholder="Describe your project..."
value={props.projectEditInput.description}
onInput={(e) => props.onProjectEditInputChange('description', e.currentTarget.value)}
disabled={props.projectPending}
rows={3}
/>
<div class="flex gap-3">
<Button type="submit" variant="primary" disabled={props.projectPending}>
{props.projectPending ? "Updating..." : "Update Project"}
</Button>
<Button type="button" variant="danger" onClick={props.onDeleteProject} disabled={props.projectPending}>
Delete Project
</Button>
</div>
</form>
<Show when={props.projectMessage}>
<Message variant="neutral" class="mt-4">{props.projectMessage}</Message>
</Show>
</Card>
</Show>
{/* Create Project Modal */}
<Modal open={showCreateModal()} onClose={() => setShowCreateModal(false)} title="Create New Project">
<form class="space-y-4" onSubmit={handleCreateSubmit}>
<Input
label="Project Name"
placeholder="My Awesome Project"
value={props.projectInput.name}
onInput={(e) => {
const name = e.currentTarget.value;
props.onProjectInputChange('name', name);
// Auto-generate slug
const slug = name.toLowerCase().trim().replace(/[^a-z0-9\s-]/g, '').replace(/\s+/g, '-').replace(/-+/g, '-');
props.onProjectInputChange('slug', slug);
}}
disabled={props.projectPending}
required
/>
<Input
label="Project Slug"
placeholder="my-awesome-project"
value={props.projectInput.slug}
onInput={(e) => props.onProjectInputChange('slug', e.currentTarget.value)}
disabled={props.projectPending}
required
/>
<Textarea
label="Description (Optional)"
placeholder="What is this project about?"
value={props.projectInput.description}
onInput={(e) => props.onProjectInputChange('description', e.currentTarget.value)}
disabled={props.projectPending}
rows={3}
/>
<div class="flex gap-3 justify-end pt-4">
<Button type="button" variant="ghost" onClick={() => setShowCreateModal(false)}>
Cancel
</Button>
<Button type="submit" variant="primary" disabled={props.projectPending}>
{props.projectPending ? "Creating..." : "Create Project"}
</Button>
</div>
</form>
<Show when={props.projectMessage}>
<Message variant="neutral" class="mt-4">{props.projectMessage}</Message>
</Show>
</Modal>
</div>
);
}
+364
View File
@@ -0,0 +1,364 @@
import { Show, For, createSignal } from "solid-js";
import { Button, Card, Input, Badge, Table, EmptyState, Message, Modal, Tabs, TabPanel } from "../components";
import type { ApiKey } from "@primora/api-client";
interface SettingsPageProps {
apiKeys?: ApiKey[];
apiKeyName: string;
apiKeySecret: string;
apiKeyMessage: string;
apiKeyPending: boolean;
organizationInput: { name: string; slug: string };
organizationEditInput: { name: string; slug: string };
organizationMessage: string;
canUpdateOrganization: boolean;
workspacePending: boolean;
onApiKeyNameChange: (name: string) => void;
onCreateApiKey: (e: SubmitEvent) => void;
onDeleteApiKey: (id: string) => void;
onOrganizationInputChange: (field: string, value: string) => void;
onOrganizationEditInputChange: (field: string, value: string) => void;
onCreateOrganization: (e: SubmitEvent) => void;
onUpdateOrganization: (e: SubmitEvent) => void;
onDeleteOrganization: () => void;
formatDate: (date?: string | null) => string;
}
export function SettingsPage(props: SettingsPageProps) {
const [activeTab, setActiveTab] = createSignal<'api-keys' | 'organization' | 'general'>('api-keys');
const [showCreateKeyModal, setShowCreateKeyModal] = createSignal(false);
const [showKeySecret, setShowKeySecret] = createSignal(false);
const [copiedKey, setCopiedKey] = createSignal(false);
const handleCreateKey = (e: SubmitEvent) => {
props.onCreateApiKey(e);
setShowCreateKeyModal(false);
setShowKeySecret(true);
};
const copyToClipboard = (text: string) => {
navigator.clipboard.writeText(text);
setCopiedKey(true);
setTimeout(() => setCopiedKey(false), 2000);
};
return (
<div class="space-y-6">
{/* Header */}
<div>
<h1 class="text-3xl font-bold text-gray-900">Settings</h1>
<p class="text-gray-600 mt-1">Manage API keys, organization, and project settings</p>
</div>
{/* Tabs */}
<Tabs
tabs={[
{ id: 'api-keys', label: 'API Keys' },
{ id: 'organization', label: 'Organization' },
{ id: 'general', label: 'General' },
]}
activeTab={activeTab()}
onChange={(id) => setActiveTab(id as any)}
/>
{/* API Keys Tab */}
<Show when={activeTab() === 'api-keys'}>
<div class="space-y-6">
<Card class="p-6">
<div class="flex items-center justify-between mb-6">
<div>
<h2 class="text-xl font-semibold">API Keys</h2>
<p class="text-sm text-gray-600 mt-1">Manage authentication keys for your project</p>
</div>
<Button onClick={() => setShowCreateKeyModal(true)}>
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4" />
</svg>
Create API Key
</Button>
</div>
<Show
when={(props.apiKeys?.length ?? 0) > 0}
fallback={
<EmptyState
icon={
<svg class="w-16 h-16" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1121 9z" />
</svg>
}
title="No API keys"
description="Create an API key to authenticate your applications"
action={
<Button onClick={() => setShowCreateKeyModal(true)}>Create API Key</Button>
}
/>
}
>
<Table>
<thead>
<tr>
<th>Name</th>
<th>Key ID</th>
<th>Status</th>
<th>Created</th>
<th>Last Used</th>
<th class="text-right">Actions</th>
</tr>
</thead>
<tbody>
<For each={props.apiKeys}>
{(key) => (
<tr>
<td class="font-medium text-gray-900">{key.name}</td>
<td>
<code class="text-xs bg-gray-100 px-2 py-1 rounded">
{key.key_id}
</code>
</td>
<td>
<Badge variant={key.is_active ? 'success' : 'secondary'}>
{key.is_active ? 'Active' : 'Inactive'}
</Badge>
</td>
<td class="text-sm text-gray-600">{props.formatDate(key.created_at)}</td>
<td class="text-sm text-gray-600">
{key.last_used_at ? props.formatDate(key.last_used_at) : 'Never'}
</td>
<td class="text-right">
<Button
variant="ghost"
size="sm"
onClick={() => props.onDeleteApiKey(key.id)}
disabled={props.apiKeyPending}
>
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
</svg>
</Button>
</td>
</tr>
)}
</For>
</tbody>
</Table>
</Show>
</Card>
<Card class="p-6 bg-blue-50 border-blue-200">
<div class="flex gap-4">
<div class="flex-shrink-0">
<svg class="w-6 h-6 text-blue-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
</div>
<div>
<h3 class="font-semibold text-blue-900 mb-1">Keep your API keys secure</h3>
<p class="text-sm text-blue-800">
API keys provide full access to your project. Never share them publicly or commit them to version control.
Store them securely using environment variables or secret management services.
</p>
</div>
</div>
</Card>
<Show when={props.apiKeyMessage}>
<Message variant="neutral">{props.apiKeyMessage}</Message>
</Show>
</div>
</Show>
{/* Organization Tab */}
<Show when={activeTab() === 'organization'}>
<div class="space-y-6">
<Card class="p-6">
<h2 class="text-xl font-semibold mb-4">Organization Settings</h2>
<form class="space-y-4" onSubmit={props.onUpdateOrganization}>
<Input
label="Organization Name"
placeholder="My Organization"
value={props.organizationEditInput.name}
onInput={(e) => props.onOrganizationEditInputChange('name', e.currentTarget.value)}
disabled={props.workspacePending || !props.canUpdateOrganization}
/>
<Input
label="Organization Slug"
placeholder="my-organization"
value={props.organizationEditInput.slug}
onInput={(e) => props.onOrganizationEditInputChange('slug', e.currentTarget.value)}
disabled={props.workspacePending || !props.canUpdateOrganization}
/>
<Show when={props.canUpdateOrganization}>
<div class="flex gap-3">
<Button type="submit" variant="primary" disabled={props.workspacePending}>
{props.workspacePending ? "Updating..." : "Update Organization"}
</Button>
<Button type="button" variant="danger" onClick={props.onDeleteOrganization} disabled={props.workspacePending}>
Delete Organization
</Button>
</div>
</Show>
</form>
<Show when={props.organizationMessage}>
<Message variant="neutral" class="mt-4">{props.organizationMessage}</Message>
</Show>
</Card>
<Card class="p-6 bg-red-50 border-red-200">
<div class="flex gap-4">
<div class="flex-shrink-0">
<svg class="w-6 h-6 text-red-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
</svg>
</div>
<div>
<h3 class="font-semibold text-red-900 mb-1">Danger Zone</h3>
<p class="text-sm text-red-800">
Deleting an organization is permanent and cannot be undone. All projects, members, API keys, buckets, and data will be permanently deleted.
</p>
</div>
</div>
</Card>
</div>
</Show>
{/* General Tab */}
<Show when={activeTab() === 'general'}>
<div class="space-y-6">
<Card class="p-6">
<h2 class="text-xl font-semibold mb-4">General Settings</h2>
<div class="space-y-4">
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">Theme</label>
<select class="input w-full max-w-xs">
<option>Light</option>
<option>Dark</option>
<option>System</option>
</select>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">Language</label>
<select class="input w-full max-w-xs">
<option>English</option>
<option>Spanish</option>
<option>French</option>
<option>German</option>
</select>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">Timezone</label>
<select class="input w-full max-w-xs">
<option>UTC</option>
<option>America/New_York</option>
<option>America/Los_Angeles</option>
<option>Europe/London</option>
<option>Asia/Tokyo</option>
</select>
</div>
</div>
</Card>
<Card class="p-6">
<h2 class="text-xl font-semibold mb-4">Notifications</h2>
<div class="space-y-3">
<label class="flex items-center gap-3">
<input type="checkbox" class="rounded border-gray-300" checked />
<div>
<p class="font-medium text-gray-900">Email notifications</p>
<p class="text-sm text-gray-600">Receive email updates about your projects</p>
</div>
</label>
<label class="flex items-center gap-3">
<input type="checkbox" class="rounded border-gray-300" checked />
<div>
<p class="font-medium text-gray-900">Security alerts</p>
<p class="text-sm text-gray-600">Get notified about security events</p>
</div>
</label>
<label class="flex items-center gap-3">
<input type="checkbox" class="rounded border-gray-300" />
<div>
<p class="font-medium text-gray-900">Product updates</p>
<p class="text-sm text-gray-600">Stay informed about new features</p>
</div>
</label>
</div>
</Card>
</div>
</Show>
{/* Create API Key Modal */}
<Modal open={showCreateKeyModal()} onClose={() => setShowCreateKeyModal(false)} title="Create API Key">
<form class="space-y-4" onSubmit={handleCreateKey}>
<Input
label="Key Name"
placeholder="Production API Key"
value={props.apiKeyName}
onInput={(e) => props.onApiKeyNameChange(e.currentTarget.value)}
disabled={props.apiKeyPending}
required
/>
<div class="bg-yellow-50 border border-yellow-200 rounded-lg p-4">
<p class="text-sm text-yellow-800">
<strong>Important:</strong> The API key secret will only be shown once. Make sure to copy and store it securely.
</p>
</div>
<div class="flex gap-3 justify-end pt-4">
<Button type="button" variant="ghost" onClick={() => setShowCreateKeyModal(false)}>
Cancel
</Button>
<Button type="submit" variant="primary" disabled={props.apiKeyPending}>
{props.apiKeyPending ? "Creating..." : "Create Key"}
</Button>
</div>
</form>
</Modal>
{/* API Key Secret Modal */}
<Modal open={showKeySecret()} onClose={() => setShowKeySecret(false)} title="API Key Created">
<div class="space-y-4">
<div class="bg-green-50 border border-green-200 rounded-lg p-4">
<p class="text-sm text-green-800 mb-2">
<strong>Success!</strong> Your API key has been created.
</p>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">API Key Secret</label>
<div class="flex gap-2">
<code class="flex-1 p-3 bg-gray-900 text-gray-100 rounded-lg text-sm font-mono break-all">
{props.apiKeySecret}
</code>
<Button
variant="outline"
onClick={() => copyToClipboard(props.apiKeySecret)}
>
{copiedKey() ? (
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7" />
</svg>
) : (
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z" />
</svg>
)}
</Button>
</div>
</div>
<div class="bg-red-50 border border-red-200 rounded-lg p-4">
<p class="text-sm text-red-800">
<strong>Warning:</strong> This is the only time you'll see this secret. Copy it now and store it securely.
</p>
</div>
<div class="flex justify-end pt-4">
<Button onClick={() => setShowKeySecret(false)}>
I've Saved the Key
</Button>
</div>
</div>
</Modal>
</div>
);
}
+441
View File
@@ -0,0 +1,441 @@
import { Show, For, createSignal } from "solid-js";
import { Button, Card, Input, Select, Badge, Table, EmptyState, Message, Modal, FileInput } from "../components";
import type { Bucket, BucketObject } from "@primora/api-client";
interface StoragePageProps {
buckets?: Bucket[];
objects?: BucketObject[];
selectedBucketID?: string;
selectedObjectKey?: string;
bucketInput: { name: string; slug: string; visibility: string };
bucketEditInput: { name: string; slug: string; visibility: string };
storageMessage: string;
storagePending: boolean;
canUpdateBucket: boolean;
objectsPage?: { items: BucketObject[]; total: number; limit: number; offset: number };
objectPreview?: any;
onBucketInputChange: (field: string, value: string) => void;
onBucketEditInputChange: (field: string, value: string) => void;
onCreateBucket: (e: SubmitEvent) => void;
onUpdateBucket: (e: SubmitEvent) => void;
onDeleteBucket: () => void;
onSelectBucket: (id: string) => void;
onSelectObject: (key: string) => void;
onUploadObject: (file: File) => void;
onDeleteObject: (key: string) => void;
onDownloadObject: (bucketId: string, key: string) => void;
onObjectPageChange: (offset: number) => void;
formatBytes: (bytes: number) => string;
formatDate: (date?: string | null) => string;
}
export function StoragePage(props: StoragePageProps) {
const [showCreateBucketModal, setShowCreateBucketModal] = createSignal(false);
const [showUploadModal, setShowUploadModal] = createSignal(false);
const [showObjectPreview, setShowObjectPreview] = createSignal(false);
const [searchQuery, setSearchQuery] = createSignal("");
const [selectedFile, setSelectedFile] = createSignal<File | undefined>();
const filteredBuckets = () => {
const query = searchQuery().toLowerCase();
if (!query || !props.buckets) return props.buckets || [];
return props.buckets.filter(b =>
b.name.toLowerCase().includes(query) ||
b.slug.toLowerCase().includes(query)
);
};
const handleCreateBucket = (e: SubmitEvent) => {
props.onCreateBucket(e);
setShowCreateBucketModal(false);
};
const handleUpload = () => {
const file = selectedFile();
if (file) {
props.onUploadObject(file);
setSelectedFile(undefined);
setShowUploadModal(false);
}
};
const getVisibilityBadge = (visibility: string) => {
return visibility === 'public' ?
<Badge variant="success">Public</Badge> :
<Badge variant="secondary">Private</Badge>;
};
return (
<div class="space-y-6">
{/* Header */}
<div class="flex items-center justify-between">
<div>
<h1 class="text-3xl font-bold text-gray-900">Storage</h1>
<p class="text-gray-600 mt-1">Manage buckets and objects</p>
</div>
<div class="flex gap-3">
<Show when={props.selectedBucketID}>
<Button variant="secondary" onClick={() => setShowUploadModal(true)}>
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="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>
Upload File
</Button>
</Show>
<Button onClick={() => setShowCreateBucketModal(true)}>
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4" />
</svg>
New Bucket
</Button>
</div>
</div>
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6">
{/* Buckets List */}
<div class="lg:col-span-1 space-y-4">
<Card class="p-4">
<Input
placeholder="Search buckets..."
value={searchQuery()}
onInput={(e) => setSearchQuery(e.currentTarget.value)}
class="w-full"
/>
</Card>
<Card class="p-4">
<h2 class="text-lg font-semibold mb-4">Buckets</h2>
<Show
when={filteredBuckets().length > 0}
fallback={
<EmptyState
icon={
<svg class="w-12 h-12" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M5 8h14M5 8a2 2 0 110-4h14a2 2 0 110 4M5 8v10a2 2 0 002 2h10a2 2 0 002-2V8m-9 3h4m-4 4h4" />
</svg>
}
title="No buckets"
description="Create a bucket to store files"
/>
}
>
<div class="space-y-2">
<For each={filteredBuckets()}>
{(bucket) => (
<button
onClick={() => props.onSelectBucket(bucket.id)}
class={`w-full text-left p-3 rounded-lg border transition-all ${
props.selectedBucketID === bucket.id
? 'border-blue-500 bg-blue-50'
: 'border-gray-200 hover:border-gray-300 hover:bg-gray-50'
}`}
>
<div class="flex items-center justify-between mb-1">
<span class="font-medium text-gray-900">{bucket.name}</span>
{getVisibilityBadge(bucket.visibility)}
</div>
<p class="text-xs text-gray-600">{bucket.slug}</p>
<p class="text-xs text-gray-500 mt-1">
{bucket.object_count} objects {props.formatBytes(bucket.size_bytes)}
</p>
</button>
)}
</For>
</div>
</Show>
</Card>
{/* Bucket Settings */}
<Show when={props.selectedBucketID && props.canUpdateBucket}>
<Card class="p-4">
<h2 class="text-lg font-semibold mb-4">Bucket Settings</h2>
<form class="space-y-3" onSubmit={props.onUpdateBucket}>
<Input
label="Name"
value={props.bucketEditInput.name}
onInput={(e) => props.onBucketEditInputChange('name', e.currentTarget.value)}
disabled={props.storagePending}
/>
<Input
label="Slug"
value={props.bucketEditInput.slug}
onInput={(e) => props.onBucketEditInputChange('slug', e.currentTarget.value)}
disabled={props.storagePending}
/>
<Select
label="Visibility"
value={props.bucketEditInput.visibility}
onChange={(e) => props.onBucketEditInputChange('visibility', e.currentTarget.value)}
disabled={props.storagePending}
>
<option value="private">Private</option>
<option value="public">Public</option>
</Select>
<div class="flex flex-col gap-2">
<Button type="submit" variant="primary" size="sm" disabled={props.storagePending}>
Update
</Button>
<Button type="button" variant="danger" size="sm" onClick={props.onDeleteBucket} disabled={props.storagePending}>
Delete Bucket
</Button>
</div>
</form>
</Card>
</Show>
</div>
{/* Objects List */}
<div class="lg:col-span-2">
<Show
when={props.selectedBucketID}
fallback={
<Card class="p-12">
<EmptyState
icon={
<svg class="w-16 h-16" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M5 8h14M5 8a2 2 0 110-4h14a2 2 0 110 4M5 8v10a2 2 0 002 2h10a2 2 0 002-2V8m-9 3h4m-4 4h4" />
</svg>
}
title="Select a bucket"
description="Choose a bucket from the left to view its contents"
/>
</Card>
}
>
<Card>
<div class="p-4 border-b border-gray-200">
<div class="flex items-center justify-between">
<h2 class="text-lg font-semibold">Objects</h2>
<Button size="sm" onClick={() => setShowUploadModal(true)}>
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="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>
Upload
</Button>
</div>
</div>
<Show
when={(props.objects?.length ?? 0) > 0}
fallback={
<div class="p-12">
<EmptyState
icon={
<svg class="w-16 h-16" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z" />
</svg>
}
title="No objects"
description="Upload files to this bucket"
action={
<Button onClick={() => setShowUploadModal(true)}>Upload File</Button>
}
/>
</div>
}
>
<Table>
<thead>
<tr>
<th>Name</th>
<th>Size</th>
<th>Type</th>
<th>Modified</th>
<th class="text-right">Actions</th>
</tr>
</thead>
<tbody>
<For each={props.objects}>
{(object) => (
<tr class="hover:bg-gray-50">
<td>
<button
onClick={() => {
props.onSelectObject(object.object_key);
setShowObjectPreview(true);
}}
class="flex items-center gap-2 text-left hover:text-blue-600"
>
<svg class="w-5 h-5 text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z" />
</svg>
<span class="font-medium">{object.object_key}</span>
</button>
</td>
<td class="text-sm text-gray-600">{props.formatBytes(object.size_bytes)}</td>
<td>
<Badge variant="secondary" class="text-xs">
{object.content_type || 'unknown'}
</Badge>
</td>
<td class="text-sm text-gray-600">{props.formatDate(object.updated_at)}</td>
<td class="text-right">
<div class="flex items-center justify-end gap-2">
<Button
variant="ghost"
size="sm"
onClick={() => props.onDownloadObject(props.selectedBucketID!, object.object_key)}
>
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" />
</svg>
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => props.onDeleteObject(object.object_key)}
>
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
</svg>
</Button>
</div>
</td>
</tr>
)}
</For>
</tbody>
</Table>
{/* Pagination */}
<Show when={props.objectsPage && props.objectsPage.total > props.objectsPage.limit}>
<div class="p-4 border-t border-gray-200 flex items-center justify-between">
<p class="text-sm text-gray-600">
Showing {props.objectsPage!.offset + 1} to {Math.min(props.objectsPage!.offset + props.objectsPage!.limit, props.objectsPage!.total)} of {props.objectsPage!.total}
</p>
<div class="flex gap-2">
<Button
variant="ghost"
size="sm"
disabled={props.objectsPage!.offset === 0}
onClick={() => props.onObjectPageChange(Math.max(0, props.objectsPage!.offset - props.objectsPage!.limit))}
>
Previous
</Button>
<Button
variant="ghost"
size="sm"
disabled={props.objectsPage!.offset + props.objectsPage!.limit >= props.objectsPage!.total}
onClick={() => props.onObjectPageChange(props.objectsPage!.offset + props.objectsPage!.limit)}
>
Next
</Button>
</div>
</div>
</Show>
</Show>
</Card>
</Show>
</div>
</div>
<Show when={props.storageMessage}>
<Message variant="neutral">{props.storageMessage}</Message>
</Show>
{/* Create Bucket Modal */}
<Modal open={showCreateBucketModal()} onClose={() => setShowCreateBucketModal(false)} title="Create New Bucket">
<form class="space-y-4" onSubmit={handleCreateBucket}>
<Input
label="Bucket Name"
placeholder="my-bucket"
value={props.bucketInput.name}
onInput={(e) => {
const name = e.currentTarget.value;
props.onBucketInputChange('name', name);
const slug = name.toLowerCase().trim().replace(/[^a-z0-9\s-]/g, '').replace(/\s+/g, '-');
props.onBucketInputChange('slug', slug);
}}
disabled={props.storagePending}
required
/>
<Input
label="Bucket Slug"
placeholder="my-bucket"
value={props.bucketInput.slug}
onInput={(e) => props.onBucketInputChange('slug', e.currentTarget.value)}
disabled={props.storagePending}
required
/>
<Select
label="Visibility"
value={props.bucketInput.visibility}
onChange={(e) => props.onBucketInputChange('visibility', e.currentTarget.value)}
disabled={props.storagePending}
>
<option value="private">Private</option>
<option value="public">Public</option>
</Select>
<div class="flex gap-3 justify-end pt-4">
<Button type="button" variant="ghost" onClick={() => setShowCreateBucketModal(false)}>
Cancel
</Button>
<Button type="submit" variant="primary" disabled={props.storagePending}>
{props.storagePending ? "Creating..." : "Create Bucket"}
</Button>
</div>
</form>
</Modal>
{/* Upload Modal */}
<Modal open={showUploadModal()} onClose={() => setShowUploadModal(false)} title="Upload File">
<div class="space-y-4">
<FileInput
label="Select File"
onChange={(file) => setSelectedFile(file)}
accept="*/*"
/>
<Show when={selectedFile()}>
<div class="p-4 bg-gray-50 rounded-lg">
<p class="text-sm font-medium text-gray-900">{selectedFile()!.name}</p>
<p class="text-xs text-gray-600 mt-1">
{props.formatBytes(selectedFile()!.size)} {selectedFile()!.type || 'unknown type'}
</p>
</div>
</Show>
<div class="flex gap-3 justify-end pt-4">
<Button type="button" variant="ghost" onClick={() => setShowUploadModal(false)}>
Cancel
</Button>
<Button
variant="primary"
disabled={!selectedFile() || props.storagePending}
onClick={handleUpload}
>
{props.storagePending ? "Uploading..." : "Upload"}
</Button>
</div>
</div>
</Modal>
{/* Object Preview Modal */}
<Modal
open={showObjectPreview()}
onClose={() => setShowObjectPreview(false)}
title="Object Preview"
size="lg"
>
<Show when={props.objectPreview}>
<div class="space-y-4">
<Show when={props.objectPreview.kind === 'image'}>
<img src={props.objectPreview.objectURL} alt="Preview" class="w-full rounded-lg" />
</Show>
<Show when={props.objectPreview.kind === 'text'}>
<pre class="p-4 bg-gray-900 text-gray-100 rounded-lg overflow-x-auto text-sm">
{props.objectPreview.text}
</pre>
<Show when={props.objectPreview.truncated}>
<p class="text-sm text-yellow-600">Preview truncated. Download to view full content.</p>
</Show>
</Show>
<Show when={props.objectPreview.kind === 'unsupported'}>
<div class="text-center p-8">
<p class="text-gray-600">{props.objectPreview.message}</p>
</div>
</Show>
</div>
</Show>
</Modal>
</div>
);
}
+6
View File
@@ -0,0 +1,6 @@
// Pages barrel export
export { ProjectsPage } from "./ProjectsPage";
export { MembersPage } from "./MembersPage";
export { StoragePage } from "./StoragePage";
export { SettingsPage } from "./SettingsPage";
export { AuditPage } from "./AuditPage";
+539
View File
@@ -0,0 +1,539 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Primora - Neo-Brutalist Design System Showcase</title>
<link rel="stylesheet" href="./styles/distinctive.css">
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
min-height: 100vh;
background: #000;
color: #fff;
font-family: 'IBM Plex Mono', monospace;
overflow-x: hidden;
}
.showcase {
max-width: 1400px;
margin: 0 auto;
padding: 80px 40px;
}
.hero {
margin-bottom: 120px;
position: relative;
}
.hero-title {
font-family: 'JetBrains Mono', monospace;
font-size: clamp(3rem, 8vw, 6rem);
font-weight: 800;
text-transform: uppercase;
letter-spacing: -0.03em;
line-height: 0.9;
margin-bottom: 40px;
position: relative;
}
.hero-title span {
display: block;
animation: slide-in 0.8s cubic-bezier(0.4, 0, 0.2, 1) backwards;
}
.hero-title span:nth-child(1) {
color: #00ffff;
text-shadow: 0 0 40px rgba(0, 255, 255, 0.5);
animation-delay: 0ms;
}
.hero-title span:nth-child(2) {
color: #ff00ff;
text-shadow: 0 0 40px rgba(255, 0, 255, 0.5);
animation-delay: 100ms;
}
.hero-title span:nth-child(3) {
color: #ffff00;
text-shadow: 0 0 40px rgba(255, 255, 0, 0.5);
animation-delay: 200ms;
}
@keyframes slide-in {
from {
opacity: 0;
transform: translateX(-100px);
}
}
.hero-subtitle {
font-size: 1.2rem;
color: #808080;
max-width: 600px;
line-height: 1.6;
animation: fade-in 1s ease-out 0.5s backwards;
}
@keyframes fade-in {
from { opacity: 0; }
}
.section {
margin-bottom: 100px;
}
.section-title {
font-family: 'JetBrains Mono', monospace;
font-size: 2rem;
font-weight: 800;
text-transform: uppercase;
color: #00ffff;
margin-bottom: 40px;
position: relative;
display: inline-block;
}
.section-title::after {
content: '';
position: absolute;
bottom: -8px;
left: 0;
right: 0;
height: 2px;
background: #00ffff;
box-shadow: 0 0 10px rgba(0, 255, 255, 0.5);
}
.grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
gap: 32px;
}
.data-card {
background: #000;
border: 2px solid #00ffff;
padding: 32px;
position: relative;
box-shadow:
0 0 20px rgba(0, 255, 255, 0.2),
inset 0 0 20px rgba(0, 255, 255, 0.1);
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
}
.data-card:hover {
transform: translate(-4px, -4px);
box-shadow:
8px 8px 0 #00ffff,
0 0 40px rgba(0, 255, 255, 0.4);
}
.data-card::before {
content: '';
position: absolute;
inset: 0;
background: repeating-linear-gradient(
0deg,
transparent,
transparent 2px,
#00ffff 2px,
#00ffff 4px
);
opacity: 0.05;
pointer-events: none;
}
.data-card-label {
font-size: 0.75rem;
text-transform: uppercase;
letter-spacing: 0.15em;
color: #00ffff;
margin-bottom: 16px;
font-weight: 600;
}
.data-card-value {
font-family: 'Space Mono', monospace;
font-size: 3.5rem;
font-weight: 700;
color: #00ffff;
text-shadow: 0 0 20px rgba(0, 255, 255, 0.5);
font-variant-numeric: tabular-nums;
line-height: 1;
margin-bottom: 8px;
}
.data-card-unit {
font-size: 1rem;
color: #808080;
font-family: 'Space Mono', monospace;
}
.data-card-trend {
margin-top: 16px;
font-size: 0.875rem;
color: #00ff00;
font-family: 'Space Mono', monospace;
}
/* Corner brackets */
.brackets {
position: relative;
}
.brackets::before,
.brackets::after {
content: '';
position: absolute;
width: 16px;
height: 16px;
border: 2px solid #00ffff;
}
.brackets::before {
top: 0;
left: 0;
border-right: none;
border-bottom: none;
}
.brackets::after {
bottom: 0;
right: 0;
border-left: none;
border-top: none;
}
/* Terminal window */
.terminal {
background: #000;
border: 2px solid #00ff00;
padding: 0;
box-shadow: 0 0 20px rgba(0, 255, 0, 0.3);
font-family: 'Space Mono', monospace;
}
.terminal-header {
background: #00ff00;
color: #000;
padding: 12px 20px;
font-weight: 700;
display: flex;
align-items: center;
gap: 8px;
}
.terminal-dot {
width: 12px;
height: 12px;
border-radius: 50%;
background: #000;
}
.terminal-body {
padding: 24px;
font-size: 0.875rem;
line-height: 1.8;
}
.terminal-line {
display: flex;
gap: 12px;
margin-bottom: 8px;
}
.terminal-prompt {
color: #00ff00;
user-select: none;
}
.terminal-cursor {
display: inline-block;
width: 8px;
height: 16px;
background: #00ff00;
animation: blink 1s step-end infinite;
margin-left: 4px;
}
@keyframes blink {
50% { opacity: 0; }
}
/* Button showcase */
.button-grid {
display: flex;
flex-wrap: wrap;
gap: 16px;
}
.neo-btn {
font-family: 'JetBrains Mono', monospace;
background: #000;
border: 2px solid;
padding: 16px 32px;
text-transform: uppercase;
font-weight: 700;
letter-spacing: 0.1em;
cursor: pointer;
position: relative;
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
font-size: 0.875rem;
}
.neo-btn:hover {
transform: translate(-2px, -2px);
}
.neo-btn:active {
transform: translate(0, 0);
}
.neo-btn-cyan {
border-color: #00ffff;
color: #00ffff;
}
.neo-btn-cyan:hover {
background: #00ffff;
color: #000;
box-shadow: 0 0 20px rgba(0, 255, 255, 0.5);
}
.neo-btn-magenta {
border-color: #ff00ff;
color: #ff00ff;
}
.neo-btn-magenta:hover {
background: #ff00ff;
color: #000;
box-shadow: 0 0 20px rgba(255, 0, 255, 0.5);
}
.neo-btn-yellow {
border-color: #ffff00;
color: #ffff00;
}
.neo-btn-yellow:hover {
background: #ffff00;
color: #000;
box-shadow: 0 0 20px rgba(255, 255, 0, 0.5);
}
/* Animated background grid */
.bg-grid {
position: fixed;
inset: 0;
background-image:
linear-gradient(#00ffff 1px, transparent 1px),
linear-gradient(90deg, #00ffff 1px, transparent 1px);
background-size: 40px 40px;
opacity: 0.03;
animation: grid-move 20s linear infinite;
pointer-events: none;
z-index: -1;
}
@keyframes grid-move {
0% { background-position: 0 0; }
100% { background-position: 40px 40px; }
}
/* Status indicators */
.status-row {
display: flex;
gap: 32px;
align-items: center;
}
.status-item {
display: flex;
align-items: center;
gap: 12px;
font-family: 'Space Mono', monospace;
font-size: 0.875rem;
}
.status-dot {
width: 12px;
height: 12px;
border-radius: 50%;
position: relative;
}
.status-dot::before {
content: '';
position: absolute;
inset: -6px;
border-radius: 50%;
background: currentColor;
opacity: 0.3;
animation: pulse-ring 2s ease-out infinite;
}
@keyframes pulse-ring {
0% {
transform: scale(0.8);
opacity: 0.5;
}
100% {
transform: scale(1.8);
opacity: 0;
}
}
.status-online {
background: #00ff00;
box-shadow: 0 0 10px rgba(0, 255, 0, 0.5);
color: #00ff00;
}
.status-warning {
background: #ffff00;
box-shadow: 0 0 10px rgba(255, 255, 0, 0.5);
color: #ffff00;
}
.status-error {
background: #ff0000;
box-shadow: 0 0 10px rgba(255, 0, 0, 0.5);
color: #ff0000;
}
</style>
</head>
<body>
<div class="bg-grid"></div>
<div class="showcase">
<!-- Hero -->
<div class="hero">
<h1 class="hero-title">
<span>NEO-</span>
<span>BRUTALIST</span>
<span>PRIMORA</span>
</h1>
<p class="hero-subtitle">
A distinctive design system where terminal aesthetics meet kinetic typography.
Raw materials. Honest borders. Data that breathes.
</p>
</div>
<!-- Data Cards -->
<section class="section">
<h2 class="section-title">Data Cards</h2>
<div class="grid">
<div class="data-card brackets">
<div class="data-card-label">Active Users</div>
<div class="data-card-value">1,247</div>
<div class="data-card-unit">online now</div>
<div class="data-card-trend">↑ 23.5% from yesterday</div>
</div>
<div class="data-card brackets" style="border-color: #ff00ff; box-shadow: 0 0 20px rgba(255, 0, 255, 0.2), inset 0 0 20px rgba(255, 0, 255, 0.1);">
<div class="data-card-label" style="color: #ff00ff;">Storage Used</div>
<div class="data-card-value" style="color: #ff00ff; text-shadow: 0 0 20px rgba(255, 0, 255, 0.5);">847</div>
<div class="data-card-unit">GB / 2 TB</div>
<div class="data-card-trend">↑ 12.3% this week</div>
</div>
<div class="data-card brackets" style="border-color: #ffff00; box-shadow: 0 0 20px rgba(255, 255, 0, 0.2), inset 0 0 20px rgba(255, 255, 0, 0.1);">
<div class="data-card-label" style="color: #ffff00;">API Requests</div>
<div class="data-card-value" style="color: #ffff00; text-shadow: 0 0 20px rgba(255, 255, 0, 0.5);">2.4M</div>
<div class="data-card-unit">last 24h</div>
<div class="data-card-trend">↑ 45.2% from last week</div>
</div>
</div>
</section>
<!-- Terminal -->
<section class="section">
<h2 class="section-title">Terminal Interface</h2>
<div class="terminal">
<div class="terminal-header">
<div class="terminal-dot"></div>
<div class="terminal-dot"></div>
<div class="terminal-dot"></div>
<span>primora@system:~</span>
</div>
<div class="terminal-body">
<div class="terminal-line">
<span class="terminal-prompt">$</span>
<span>primora status --verbose</span>
</div>
<div class="terminal-line">
<span style="color: #00ff00;"></span>
<span>Backend API: <span style="color: #00ff00;">ONLINE</span></span>
</div>
<div class="terminal-line">
<span style="color: #00ff00;"></span>
<span>Database: <span style="color: #00ff00;">CONNECTED</span></span>
</div>
<div class="terminal-line">
<span style="color: #00ff00;"></span>
<span>Storage: <span style="color: #00ff00;">READY</span></span>
</div>
<div class="terminal-line">
<span class="terminal-prompt">$</span>
<span class="terminal-cursor"></span>
</div>
</div>
</div>
</section>
<!-- Buttons -->
<section class="section">
<h2 class="section-title">Interactive Elements</h2>
<div class="button-grid">
<button class="neo-btn neo-btn-cyan">Primary Action</button>
<button class="neo-btn neo-btn-magenta">Secondary</button>
<button class="neo-btn neo-btn-yellow">Warning</button>
</div>
</section>
<!-- Status Indicators -->
<section class="section">
<h2 class="section-title">System Status</h2>
<div class="status-row">
<div class="status-item">
<div class="status-dot status-online"></div>
<span style="color: #00ff00;">API Online</span>
</div>
<div class="status-item">
<div class="status-dot status-online"></div>
<span style="color: #00ff00;">Database Connected</span>
</div>
<div class="status-item">
<div class="status-dot status-warning"></div>
<span style="color: #ffff00;">High Load</span>
</div>
</div>
</section>
</div>
<script>
// Animate numbers counting up
document.querySelectorAll('.data-card-value').forEach(el => {
const target = parseInt(el.textContent.replace(/,/g, ''));
let current = 0;
const increment = target / 60;
const timer = setInterval(() => {
current += increment;
if (current >= target) {
el.textContent = target.toLocaleString();
clearInterval(timer);
} else {
el.textContent = Math.floor(current).toLocaleString();
}
}, 20);
});
</script>
</body>
</html>
+461
View File
@@ -0,0 +1,461 @@
/**
* PRIMORA DISTINCTIVE DESIGN SYSTEM
* Refined Cyberpunk meets Swiss Precision
*
* Core Principles:
* - Precision typography with character
* - Layered depth through transparency
* - Kinetic micro-interactions
* - Data-first visual language
* - Atmospheric backgrounds
*/
/* ============================================
TYPOGRAPHY - Distinctive Font Choices
============================================ */
@import url('https://fonts.googleapis.com/css2?family=Syne:wght@400;600;700;800&family=Azeret+Mono:wght@400;500;600;700&family=DM+Mono:wght@400;500&display=swap');
:root {
/* Typography - Distinctive & Refined */
--font-display: 'Syne', system-ui, sans-serif;
--font-body: 'Azeret Mono', 'Courier New', monospace;
--font-data: 'DM Mono', monospace;
/* Refined Cyberpunk Palette */
--electric-blue: #00d9ff;
--neon-purple: #b794f6;
--cyber-pink: #ff6ec7;
--matrix-green: #00ff9f;
--warning-amber: #ffb84d;
--danger-red: #ff5757;
--pure-white: #ffffff;
--deep-black: #0a0a0f;
--slate-gray: #8892b0;
/* Atmospheric Glows */
--glow-blue: rgba(0, 217, 255, 0.4);
--glow-purple: rgba(183, 148, 246, 0.4);
--glow-pink: rgba(255, 110, 199, 0.4);
--glow-green: rgba(0, 255, 159, 0.4);
--glow-ambient: rgba(25, 163, 217, 0.15);
/* Layered Backgrounds */
--bg-layer-0: #0a0a0f;
--bg-layer-1: rgba(15, 15, 25, 0.8);
--bg-layer-2: rgba(20, 20, 35, 0.9);
--bg-layer-3: rgba(30, 30, 50, 0.95);
/* Glass Morphism */
--glass-bg: rgba(255, 255, 255, 0.03);
--glass-border: rgba(255, 255, 255, 0.08);
--glass-blur: blur(20px);
/* Grid System */
--grid-size: 8px;
--border-width: 1px;
--border-width-thick: 2px;
}
/* ============================================
BASE STYLES - Refined Cyberpunk Aesthetic
============================================ */
body {
font-family: var(--font-body);
background: var(--bg-layer-0);
color: var(--pure-white);
font-variant-numeric: tabular-nums;
letter-spacing: -0.01em;
position: relative;
overflow-x: hidden;
}
/* Atmospheric background */
body::before {
content: '';
position: fixed;
inset: 0;
background:
radial-gradient(circle at 20% 30%, var(--glow-blue) 0%, transparent 50%),
radial-gradient(circle at 80% 70%, var(--glow-purple) 0%, transparent 50%),
radial-gradient(circle at 50% 50%, var(--glow-pink) 0%, transparent 70%);
opacity: 0.15;
pointer-events: none;
z-index: 0;
animation: atmospheric-shift 20s ease-in-out infinite;
}
@keyframes atmospheric-shift {
0%, 100% { transform: translate(0, 0) scale(1); }
33% { transform: translate(5%, -5%) scale(1.1); }
66% { transform: translate(-5%, 5%) scale(0.9); }
}
/* Typography with character */
h1, h2, h3, h4, h5, h6 {
font-family: var(--font-display);
font-weight: 700;
letter-spacing: -0.03em;
line-height: 1.2;
color: var(--pure-white);
}
h1 {
font-size: clamp(2rem, 5vw, 3.5rem);
font-weight: 800;
background: linear-gradient(135deg, var(--electric-blue), var(--neon-purple));
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
/* ============================================
NEO-BRUTALIST COMPONENTS
============================================ */
.neo-card {
background: var(--neo-black);
border: var(--border-width) solid var(--neo-cyan);
padding: calc(var(--grid-size) * 3);
position: relative;
box-shadow:
0 0 20px var(--glow-cyan),
inset 0 0 20px var(--glow-cyan);
}
.neo-card::before {
content: '';
position: absolute;
inset: 0;
background: repeating-linear-gradient(
0deg,
transparent,
transparent 2px,
var(--neo-cyan) 2px,
var(--neo-cyan) 4px
);
opacity: 0.05;
pointer-events: none;
animation: scanline 8s linear infinite;
}
.neo-button {
font-family: var(--font-display);
background: var(--neo-black);
border: var(--border-width) solid var(--neo-cyan);
color: var(--neo-cyan);
padding: calc(var(--grid-size) * 1.5) calc(var(--grid-size) * 3);
text-transform: uppercase;
font-weight: 700;
letter-spacing: 0.1em;
cursor: pointer;
position: relative;
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
}
.neo-button:hover {
background: var(--neo-cyan);
color: var(--neo-black);
box-shadow: 0 0 20px var(--glow-cyan);
transform: translate(-2px, -2px);
}
.neo-button:active {
transform: translate(0, 0);
}
/* Corner brackets for emphasis */
.neo-brackets {
position: relative;
}
.neo-brackets::before,
.neo-brackets::after {
content: '';
position: absolute;
width: 12px;
height: 12px;
border: var(--border-width) solid currentColor;
}
.neo-brackets::before {
top: 0;
left: 0;
border-right: none;
border-bottom: none;
}
.neo-brackets::after {
bottom: 0;
right: 0;
border-left: none;
border-top: none;
}
/* ============================================
KINETIC TYPOGRAPHY
============================================ */
.kinetic-number {
font-family: var(--font-data);
font-variant-numeric: tabular-nums;
font-weight: 700;
display: inline-block;
animation: number-pulse 2s ease-in-out infinite;
}
@keyframes number-pulse {
0%, 100% {
transform: scale(1);
text-shadow: 0 0 10px currentColor;
}
50% {
transform: scale(1.05);
text-shadow: 0 0 20px currentColor;
}
}
/* Staggered reveal for data */
.stagger-reveal > * {
opacity: 0;
transform: translateY(20px);
animation: reveal 0.6s cubic-bezier(0.4, 0, 0.2, 1) forwards;
}
.stagger-reveal > *:nth-child(1) { animation-delay: 0ms; }
.stagger-reveal > *:nth-child(2) { animation-delay: 100ms; }
.stagger-reveal > *:nth-child(3) { animation-delay: 200ms; }
.stagger-reveal > *:nth-child(4) { animation-delay: 300ms; }
.stagger-reveal > *:nth-child(5) { animation-delay: 400ms; }
.stagger-reveal > *:nth-child(6) { animation-delay: 500ms; }
@keyframes reveal {
to {
opacity: 1;
transform: translateY(0);
}
}
/* ============================================
TERMINAL EFFECTS
============================================ */
.terminal-cursor::after {
content: '▊';
animation: blink 1s step-end infinite;
margin-left: 2px;
}
@keyframes blink {
50% { opacity: 0; }
}
.scanline {
position: relative;
overflow: hidden;
}
.scanline::before {
content: '';
position: absolute;
inset: 0;
background: repeating-linear-gradient(
0deg,
transparent,
transparent 2px,
rgba(255, 255, 255, 0.03) 2px,
rgba(255, 255, 255, 0.03) 4px
);
pointer-events: none;
animation: scanline 8s linear infinite;
}
@keyframes scanline {
0% { transform: translateY(-100%); }
100% { transform: translateY(100%); }
}
/* CRT screen effect */
.crt-effect {
position: relative;
}
.crt-effect::before {
content: '';
position: absolute;
inset: 0;
background:
radial-gradient(ellipse at center, transparent 0%, rgba(0, 0, 0, 0.3) 100%);
pointer-events: none;
}
/* ============================================
DATA VISUALIZATION ELEMENTS
============================================ */
.data-bar {
height: 4px;
background: var(--neo-cyan);
box-shadow: 0 0 10px var(--glow-cyan);
animation: data-flow 2s ease-in-out infinite;
}
@keyframes data-flow {
0%, 100% { opacity: 0.5; }
50% { opacity: 1; }
}
.data-grid {
background-image:
linear-gradient(var(--neo-cyan) 1px, transparent 1px),
linear-gradient(90deg, var(--neo-cyan) 1px, transparent 1px);
background-size: var(--grid-size) var(--grid-size);
opacity: 0.1;
}
/* Animated grid */
.animated-grid {
background-image:
linear-gradient(var(--neo-cyan) 1px, transparent 1px),
linear-gradient(90deg, var(--neo-cyan) 1px, transparent 1px);
background-size: 20px 20px;
opacity: 0.1;
animation: grid-move 20s linear infinite;
}
@keyframes grid-move {
0% { background-position: 0 0; }
100% { background-position: 20px 20px; }
}
/* ============================================
STATUS INDICATORS
============================================ */
.status-dot {
width: 8px;
height: 8px;
border-radius: 50%;
display: inline-block;
position: relative;
}
.status-dot::before {
content: '';
position: absolute;
inset: -4px;
border-radius: 50%;
background: currentColor;
opacity: 0.3;
animation: pulse-ring 2s ease-out infinite;
}
@keyframes pulse-ring {
0% {
transform: scale(0.8);
opacity: 0.5;
}
100% {
transform: scale(1.5);
opacity: 0;
}
}
.status-online {
background: var(--neo-green);
box-shadow: 0 0 10px var(--glow-green);
}
.status-offline {
background: var(--neo-red);
box-shadow: 0 0 10px rgba(255, 0, 0, 0.3);
}
.status-warning {
background: var(--neo-yellow);
box-shadow: 0 0 10px var(--glow-yellow);
}
/* ============================================
INTERACTIVE STATES
============================================ */
.neo-hover {
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
}
.neo-hover:hover {
transform: translate(-2px, -2px);
box-shadow:
4px 4px 0 var(--neo-cyan),
0 0 20px var(--glow-cyan);
}
/* Glitch effect on hover */
.glitch-hover {
position: relative;
}
.glitch-hover:hover::before,
.glitch-hover:hover::after {
content: attr(data-text);
position: absolute;
inset: 0;
opacity: 0.8;
}
.glitch-hover:hover::before {
color: var(--neo-cyan);
animation: glitch-1 0.3s infinite;
clip-path: polygon(0 0, 100% 0, 100% 45%, 0 45%);
}
.glitch-hover:hover::after {
color: var(--neo-magenta);
animation: glitch-2 0.3s infinite;
clip-path: polygon(0 55%, 100% 55%, 100% 100%, 0 100%);
}
@keyframes glitch-1 {
0%, 100% { transform: translate(0); }
33% { transform: translate(-2px, 2px); }
66% { transform: translate(2px, -2px); }
}
@keyframes glitch-2 {
0%, 100% { transform: translate(0); }
33% { transform: translate(2px, -2px); }
66% { transform: translate(-2px, 2px); }
}
/* ============================================
UTILITY CLASSES
============================================ */
.text-cyan { color: var(--neo-cyan); }
.text-magenta { color: var(--neo-magenta); }
.text-yellow { color: var(--neo-yellow); }
.text-green { color: var(--neo-green); }
.text-red { color: var(--neo-red); }
.border-cyan { border-color: var(--neo-cyan); }
.border-magenta { border-color: var(--neo-magenta); }
.border-yellow { border-color: var(--neo-yellow); }
.border-green { border-color: var(--neo-green); }
.glow-cyan { box-shadow: 0 0 20px var(--glow-cyan); }
.glow-magenta { box-shadow: 0 0 20px var(--glow-magenta); }
.glow-yellow { box-shadow: 0 0 20px var(--glow-yellow); }
.glow-green { box-shadow: 0 0 20px var(--glow-green); }
/* Monospace utilities */
.font-display { font-family: var(--font-display); }
.font-data { font-family: var(--font-data); }
.tabular-nums { font-variant-numeric: tabular-nums; }
/* Grid utilities */
.grid-8 { width: calc(var(--grid-size) * 8); }
.grid-16 { width: calc(var(--grid-size) * 16); }
.grid-24 { width: calc(var(--grid-size) * 24); }
.grid-32 { width: calc(var(--grid-size) * 32); }
+516
View File
@@ -0,0 +1,516 @@
/* PRIMORA Enhanced Visual Effects & Micro-interactions */
/* ============================================
ADVANCED ANIMATIONS
============================================ */
@keyframes float {
0%, 100% {
transform: translateY(0);
}
50% {
transform: translateY(-10px);
}
}
@keyframes pulse-glow {
0%, 100% {
box-shadow: 0 0 20px rgba(25, 163, 217, 0.2);
}
50% {
box-shadow: 0 0 40px rgba(25, 163, 217, 0.4);
}
}
@keyframes slide-in-right {
from {
opacity: 0;
transform: translateX(20px);
}
to {
opacity: 1;
transform: translateX(0);
}
}
@keyframes bounce-in {
0% {
opacity: 0;
transform: scale(0.3);
}
50% {
transform: scale(1.05);
}
70% {
transform: scale(0.9);
}
100% {
opacity: 1;
transform: scale(1);
}
}
@keyframes gradient-shift {
0%, 100% {
background-position: 0% 50%;
}
50% {
background-position: 100% 50%;
}
}
/* ============================================
ENHANCED CARD EFFECTS
============================================ */
.card-premium {
position: relative;
background: var(--surface-1);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: var(--space-5);
overflow: hidden;
transition: all var(--duration-normal) var(--ease-out);
}
.card-premium::before {
content: '';
position: absolute;
top: 0;
left: -100%;
width: 100%;
height: 100%;
background: linear-gradient(
90deg,
transparent,
rgba(25, 163, 217, 0.1),
transparent
);
transition: left 0.5s ease;
}
.card-premium:hover::before {
left: 100%;
}
.card-premium:hover {
border-color: var(--accent);
transform: translateY(-4px);
box-shadow: 0 12px 40px rgba(0, 0, 0, 0.6),
0 0 0 1px rgba(25, 163, 217, 0.1),
var(--glow-accent);
}
/* ============================================
BUTTON ENHANCEMENTS
============================================ */
.btn-glow {
position: relative;
overflow: hidden;
}
.btn-glow::after {
content: '';
position: absolute;
top: 50%;
left: 50%;
width: 0;
height: 0;
border-radius: 50%;
background: rgba(255, 255, 255, 0.3);
transform: translate(-50%, -50%);
transition: width 0.6s, height 0.6s;
}
.btn-glow:active::after {
width: 300px;
height: 300px;
}
.btn-icon-shift {
transition: all var(--duration-normal) var(--ease-out);
}
.btn-icon-shift:hover svg {
transform: translateX(4px);
transition: transform var(--duration-normal) var(--ease-spring);
}
/* ============================================
NAVIGATION ENHANCEMENTS
============================================ */
.nav-item-enhanced {
position: relative;
display: flex;
align-items: center;
gap: var(--space-3);
padding: var(--space-3) var(--space-4);
border-radius: var(--radius);
transition: all var(--duration-normal) var(--ease-out);
cursor: pointer;
}
.nav-item-enhanced::after {
content: '';
position: absolute;
right: var(--space-3);
width: 0;
height: 2px;
background: var(--accent);
transition: width var(--duration-normal) var(--ease-spring);
}
.nav-item-enhanced:hover::after {
width: 20px;
}
.nav-item-enhanced.active {
background: var(--accent-subtle);
color: var(--accent);
}
.nav-item-enhanced.active::before {
content: '';
position: absolute;
left: 0;
top: 20%;
height: 60%;
width: 3px;
background: linear-gradient(180deg, var(--accent), var(--accent-hover));
border-radius: 0 4px 4px 0;
box-shadow: 0 0 10px var(--accent);
}
/* ============================================
DATA VISUALIZATION
============================================ */
.stat-card-enhanced {
position: relative;
background: var(--surface-1);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: var(--space-5);
overflow: hidden;
transition: all var(--duration-normal) var(--ease-out);
}
.stat-card-enhanced::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
height: 2px;
background: linear-gradient(90deg, var(--accent), var(--accent-hover));
transform: scaleX(0);
transform-origin: left;
transition: transform var(--duration-normal) var(--ease-spring);
}
.stat-card-enhanced:hover::before {
transform: scaleX(1);
}
.stat-card-enhanced:hover {
border-color: var(--accent);
transform: translateY(-3px) scale(1.02);
box-shadow: var(--shadow-lg), var(--glow-accent);
}
.stat-value-animated {
font-family: var(--font-display);
font-size: 2.5rem;
font-weight: 800;
background: linear-gradient(135deg, var(--text-primary), var(--accent));
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
animation: gradient-shift 3s ease infinite;
background-size: 200% 200%;
}
/* ============================================
FORM ENHANCEMENTS
============================================ */
.input-enhanced {
position: relative;
width: 100%;
}
.input-enhanced input,
.input-enhanced textarea {
width: 100%;
background: var(--surface-1);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: var(--space-3) var(--space-4);
font-size: 0.875rem;
color: var(--text-primary);
transition: all var(--duration-normal) var(--ease-out);
}
.input-enhanced input:focus,
.input-enhanced textarea:focus {
border-color: var(--accent);
outline: none;
box-shadow: 0 0 0 3px var(--accent-muted), var(--glow-accent);
transform: translateY(-1px);
}
.input-enhanced::after {
content: '';
position: absolute;
bottom: 0;
left: 50%;
width: 0;
height: 2px;
background: var(--accent);
transform: translateX(-50%);
transition: width var(--duration-normal) var(--ease-spring);
}
.input-enhanced:focus-within::after {
width: 100%;
}
/* ============================================
TABLE ENHANCEMENTS
============================================ */
.table-enhanced {
width: 100%;
border-collapse: separate;
border-spacing: 0;
}
.table-enhanced thead tr {
background: var(--bg-elevated);
}
.table-enhanced thead th {
padding: var(--space-4);
text-align: left;
font-size: 0.75rem;
font-weight: 600;
color: var(--text-muted);
text-transform: uppercase;
letter-spacing: 0.05em;
border-bottom: 2px solid var(--border-strong);
position: relative;
}
.table-enhanced thead th::after {
content: '';
position: absolute;
bottom: -2px;
left: 0;
width: 0;
height: 2px;
background: var(--accent);
transition: width var(--duration-normal) var(--ease-out);
}
.table-enhanced thead th:hover::after {
width: 100%;
}
.table-enhanced tbody tr {
transition: all var(--duration-fast) var(--ease-out);
position: relative;
}
.table-enhanced tbody tr::before {
content: '';
position: absolute;
left: 0;
top: 0;
bottom: 0;
width: 0;
background: var(--accent);
transition: width var(--duration-fast) var(--ease-spring);
}
.table-enhanced tbody tr:hover::before {
width: 3px;
}
.table-enhanced tbody tr:hover {
background: var(--surface-2);
transform: translateX(3px);
}
.table-enhanced tbody td {
padding: var(--space-4);
border-bottom: 1px solid var(--border-subtle);
}
/* ============================================
BADGE ENHANCEMENTS
============================================ */
.badge-animated {
display: inline-flex;
align-items: center;
gap: var(--space-2);
padding: 0.375rem 0.75rem;
border-radius: var(--radius);
font-size: 0.75rem;
font-weight: 600;
position: relative;
overflow: hidden;
}
.badge-animated::before {
content: '';
position: absolute;
top: 0;
left: -100%;
width: 100%;
height: 100%;
background: linear-gradient(
90deg,
transparent,
rgba(255, 255, 255, 0.2),
transparent
);
animation: shimmer 2s infinite;
}
.badge-pulse {
animation: pulse-glow 2s ease-in-out infinite;
}
/* ============================================
LOADING STATES
============================================ */
.loading-dots {
display: inline-flex;
gap: 4px;
}
.loading-dots span {
width: 6px;
height: 6px;
border-radius: 50%;
background: var(--accent);
animation: bounce 1.4s infinite ease-in-out both;
}
.loading-dots span:nth-child(1) {
animation-delay: -0.32s;
}
.loading-dots span:nth-child(2) {
animation-delay: -0.16s;
}
@keyframes bounce {
0%, 80%, 100% {
transform: scale(0);
opacity: 0.5;
}
40% {
transform: scale(1);
opacity: 1;
}
}
/* ============================================
UTILITY CLASSES
============================================ */
.animate-float {
animation: float 3s ease-in-out infinite;
}
.animate-pulse-glow {
animation: pulse-glow 2s ease-in-out infinite;
}
.animate-slide-in-right {
animation: slide-in-right var(--duration-normal) var(--ease-out);
}
.animate-bounce-in {
animation: bounce-in 0.5s var(--ease-spring);
}
.animate-gradient-shift {
animation: gradient-shift 3s ease infinite;
background-size: 200% 200%;
}
.backdrop-blur-strong {
backdrop-filter: blur(20px);
-webkit-backdrop-filter: blur(20px);
}
.text-shadow-glow {
text-shadow: 0 0 20px var(--accent-glow);
}
.border-glow {
box-shadow: 0 0 0 1px var(--accent), var(--glow-accent);
}
/* ============================================
RESPONSIVE UTILITIES
============================================ */
@media (max-width: 768px) {
.card-premium:hover {
transform: translateY(-2px);
}
.stat-card-enhanced:hover {
transform: translateY(-2px) scale(1.01);
}
.table-enhanced tbody tr:hover {
transform: translateX(0);
}
}
/* ============================================
ACCESSIBILITY ENHANCEMENTS
============================================ */
@media (prefers-reduced-motion: reduce) {
.card-premium::before,
.btn-glow::after,
.nav-item-enhanced::after,
.stat-card-enhanced::before,
.input-enhanced::after,
.table-enhanced thead th::after,
.table-enhanced tbody tr::before,
.badge-animated::before {
transition: none;
animation: none;
}
.animate-float,
.animate-pulse-glow,
.animate-slide-in-right,
.animate-bounce-in,
.animate-gradient-shift {
animation: none;
}
}
/* High contrast mode support */
@media (prefers-contrast: high) {
.card-premium,
.stat-card-enhanced,
.nav-item-enhanced {
border-width: 2px;
}
.btn-primary {
border: 2px solid var(--accent);
}
}
+495
View File
@@ -0,0 +1,495 @@
/**
* PRIMORA LOGIN PAGE STYLES
* Clean, responsive authentication experience
*/
/* ============================================
LOGIN CONTAINER & BACKGROUND
============================================ */
.login-container {
position: fixed;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
padding: 1rem;
background: var(--bg-main);
overflow: hidden;
}
.login-background-glow {
display: none;
}
/* ============================================
LOGIN CARD
============================================ */
.login-card-wrapper {
width: 100%;
max-width: 420px;
z-index: 1;
}
.login-card {
background: var(--surface-1);
border: 1px solid var(--border);
border-radius: var(--radius-lg);
padding: 2rem;
box-shadow: var(--shadow-md);
}
@media (max-width: 640px) {
.login-card {
padding: 1.5rem;
}
}
@media (max-height: 650px) {
.login-card {
padding: 1.25rem;
}
}
/* ============================================
LOGIN HEADER
============================================ */
.login-header {
text-align: center;
margin-bottom: 1.5rem;
}
@media (max-height: 650px) {
.login-header {
margin-bottom: 1rem;
}
}
.login-logo-wrapper {
display: flex;
justify-content: center;
margin-bottom: 1rem;
}
@media (max-height: 650px) {
.login-logo-wrapper {
margin-bottom: 0.75rem;
}
}
.login-logo {
width: 48px;
height: 48px;
display: flex;
align-items: center;
justify-content: center;
border-radius: var(--radius);
background: var(--accent);
box-shadow: 0 4px 12px rgba(25, 163, 217, 0.25);
}
@media (max-height: 650px) {
.login-logo {
width: 40px;
height: 40px;
}
}
.login-logo-text {
font-family: var(--font-display);
font-size: 1.5rem;
font-weight: 700;
color: white;
}
@media (max-height: 650px) {
.login-logo-text {
font-size: 1.25rem;
}
}
.login-logo-glow {
display: none;
}
.login-title {
font-family: var(--font-display);
font-size: 1.5rem;
font-weight: 700;
color: var(--text-primary);
margin-bottom: 0.375rem;
letter-spacing: -0.02em;
}
@media (max-width: 640px) {
.login-title {
font-size: 1.375rem;
}
}
@media (max-height: 650px) {
.login-title {
font-size: 1.25rem;
margin-bottom: 0.25rem;
}
}
.login-subtitle {
font-size: 0.875rem;
color: var(--text-secondary);
line-height: 1.4;
}
@media (max-height: 650px) {
.login-subtitle {
font-size: 0.8125rem;
}
}
/* ============================================
LOGIN FORM
============================================ */
.login-form {
position: relative;
}
/* Mode Toggle */
.login-mode-toggle {
position: relative;
display: flex;
gap: 0.25rem;
padding: 0.25rem;
background: var(--bg-elevated);
border-radius: var(--radius);
margin-bottom: 1.25rem;
border: 1px solid var(--border);
}
@media (max-height: 650px) {
.login-mode-toggle {
margin-bottom: 1rem;
}
}
.login-mode-btn {
flex: 1;
position: relative;
z-index: 2;
padding: 0.5rem 0.75rem;
font-size: 0.875rem;
font-weight: 500;
color: var(--text-secondary);
background: transparent;
border: none;
border-radius: calc(var(--radius) - 2px);
cursor: pointer;
transition: color var(--duration-fast) var(--ease-out);
outline: none;
}
.login-mode-btn:hover {
color: var(--text-primary);
}
.login-mode-btn-active {
color: white;
}
.login-mode-indicator {
position: absolute;
top: 0.25rem;
left: 0.25rem;
width: calc(50% - 0.375rem);
height: calc(100% - 0.5rem);
background: var(--accent);
border-radius: calc(var(--radius) - 2px);
transition: transform var(--duration-normal) var(--ease-out);
box-shadow: 0 2px 4px rgba(25, 163, 217, 0.3);
z-index: 1;
}
.login-mode-indicator-right {
transform: translateX(calc(100% + 0.25rem));
}
/* Input Groups */
.login-inputs {
display: flex;
flex-direction: column;
gap: 0.75rem;
margin-bottom: 1rem;
}
@media (max-height: 650px) {
.login-inputs {
gap: 0.625rem;
margin-bottom: 0.875rem;
}
}
.login-input-group {
position: relative;
}
.login-input {
width: 100%;
background: var(--bg-elevated);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 0.625rem 0.875rem;
font-size: 0.875rem;
color: var(--text-primary);
transition: all var(--duration-fast) var(--ease-out);
outline: none;
}
@media (max-height: 650px) {
.login-input {
padding: 0.5rem 0.75rem;
font-size: 0.8125rem;
}
}
.login-input::placeholder {
color: var(--text-muted);
}
.login-input:hover {
border-color: var(--border-hover);
}
.login-input:focus {
border-color: var(--accent);
background: var(--surface-1);
box-shadow: 0 0 0 3px var(--accent-muted);
}
/* Submit Button */
.login-submit-btn {
width: 100%;
padding: 0.75rem 1.25rem;
font-size: 0.9375rem;
font-weight: 600;
background: var(--accent);
color: white;
border: none;
border-radius: var(--radius);
cursor: pointer;
transition: all var(--duration-fast) var(--ease-out);
box-shadow: 0 2px 8px rgba(25, 163, 217, 0.25);
display: flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
}
@media (max-height: 650px) {
.login-submit-btn {
padding: 0.625rem 1rem;
font-size: 0.875rem;
}
}
.login-submit-btn:hover:not(:disabled) {
background: var(--accent-hover);
box-shadow: 0 4px 12px rgba(25, 163, 217, 0.35);
}
.login-submit-btn:active:not(:disabled) {
transform: scale(0.98);
}
.login-submit-btn:disabled {
opacity: 0.6;
cursor: not-allowed;
}
/* Divider */
.login-divider {
display: flex;
align-items: center;
gap: 0.75rem;
margin: 1.25rem 0 1rem;
}
@media (max-height: 650px) {
.login-divider {
margin: 1rem 0 0.875rem;
}
}
.login-divider-line {
flex: 1;
height: 1px;
background: var(--border);
}
.login-divider-text {
font-size: 0.75rem;
color: var(--text-muted);
white-space: nowrap;
text-transform: uppercase;
letter-spacing: 0.05em;
font-weight: 500;
}
/* Social Buttons */
.login-social-buttons {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 0.625rem;
margin-bottom: 1rem;
}
@media (max-height: 650px) {
.login-social-buttons {
gap: 0.5rem;
margin-bottom: 0.875rem;
}
}
.login-social-btn {
display: flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
padding: 0.625rem 0.875rem;
font-size: 0.875rem;
font-weight: 500;
background: var(--bg-elevated);
color: var(--text-primary);
border: 1px solid var(--border);
border-radius: var(--radius);
cursor: pointer;
transition: all var(--duration-fast) var(--ease-out);
}
@media (max-height: 650px) {
.login-social-btn {
padding: 0.5rem 0.75rem;
font-size: 0.8125rem;
}
}
.login-social-btn svg {
width: 1.125rem;
height: 1.125rem;
flex-shrink: 0;
}
@media (max-height: 650px) {
.login-social-btn svg {
width: 1rem;
height: 1rem;
}
}
.login-social-btn:hover:not(:disabled) {
background: var(--surface-1);
border-color: var(--border-hover);
}
.login-social-btn:active:not(:disabled) {
transform: scale(0.98);
}
.login-social-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
@media (max-width: 640px) {
.login-social-buttons {
grid-template-columns: 1fr;
}
}
/* Message */
.login-message {
margin-top: 0.875rem;
}
@media (max-height: 650px) {
.login-message {
margin-top: 0.75rem;
}
}
/* ============================================
LOGIN FOOTER
============================================ */
.login-footer {
margin-top: 1.25rem;
padding-top: 1rem;
border-top: 1px solid var(--border-subtle);
text-align: center;
}
@media (max-height: 650px) {
.login-footer {
margin-top: 1rem;
padding-top: 0.875rem;
}
}
.login-footer-text {
font-size: 0.8125rem;
color: var(--text-secondary);
}
.login-footer-link {
color: var(--accent);
font-weight: 600;
background: none;
border: none;
cursor: pointer;
transition: color var(--duration-fast) var(--ease-out);
text-decoration: none;
padding: 0;
}
.login-footer-link:hover {
color: var(--accent-hover);
text-decoration: underline;
}
/* ============================================
ANIMATIONS
============================================ */
@keyframes fadeIn {
from {
opacity: 0;
transform: translateY(10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes slideUp {
from {
opacity: 0;
transform: translateY(8px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes slideInLeft {
from {
opacity: 0;
transform: translateX(-10px);
}
to {
opacity: 1;
transform: translateX(0);
}
}
+108
View File
@@ -0,0 +1,108 @@
/**
* Export utilities for downloading data in various formats
*/
/**
* Export data as JSON file
*/
export function exportJSON(data: any, filename: string) {
const json = JSON.stringify(data, null, 2);
const blob = new Blob([json], { type: "application/json" });
downloadBlob(blob, `${filename}.json`);
}
/**
* Export data as CSV file
*/
export function exportCSV(data: any[], filename: string, headers?: string[]) {
if (data.length === 0) {
throw new Error("No data to export");
}
// Get headers from first object if not provided
const csvHeaders = headers || Object.keys(data[0]);
// Create CSV content
const csvRows = [
csvHeaders.join(","), // Header row
...data.map(row =>
csvHeaders.map(header => {
const value = row[header];
// Escape quotes and wrap in quotes if contains comma or quote
const stringValue = String(value ?? "");
if (stringValue.includes(",") || stringValue.includes('"') || stringValue.includes("\n")) {
return `"${stringValue.replace(/"/g, '""')}"`;
}
return stringValue;
}).join(",")
)
];
const csv = csvRows.join("\n");
const blob = new Blob([csv], { type: "text/csv" });
downloadBlob(blob, `${filename}.csv`);
}
/**
* Export data as text file
*/
export function exportText(text: string, filename: string) {
const blob = new Blob([text], { type: "text/plain" });
downloadBlob(blob, `${filename}.txt`);
}
/**
* Download a blob as a file
*/
function downloadBlob(blob: Blob, filename: string) {
const url = URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.download = filename;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(url);
}
/**
* Copy text to clipboard
*/
export async function copyToClipboard(text: string): Promise<boolean> {
try {
await navigator.clipboard.writeText(text);
return true;
} catch (err) {
// Fallback for older browsers
const textarea = document.createElement("textarea");
textarea.value = text;
textarea.style.position = "fixed";
textarea.style.opacity = "0";
document.body.appendChild(textarea);
textarea.select();
const success = document.execCommand("copy");
document.body.removeChild(textarea);
return success;
}
}
/**
* Format data for export with timestamps
*/
export function prepareExportData(data: any[], includeTimestamp = true) {
if (!includeTimestamp) return data;
return data.map(item => ({
...item,
exported_at: new Date().toISOString(),
}));
}
/**
* Generate filename with timestamp
*/
export function generateFilename(base: string, extension?: string): string {
const timestamp = new Date().toISOString().replace(/[:.]/g, "-").slice(0, -5);
const ext = extension ? `.${extension}` : "";
return `${base}_${timestamp}${ext}`;
}
+214
View File
@@ -0,0 +1,214 @@
/**
* Search and filter utilities
*/
/**
* Fuzzy search implementation
*/
export function fuzzySearch(query: string, text: string): boolean {
const queryLower = query.toLowerCase();
const textLower = text.toLowerCase();
let queryIndex = 0;
let textIndex = 0;
while (queryIndex < queryLower.length && textIndex < textLower.length) {
if (queryLower[queryIndex] === textLower[textIndex]) {
queryIndex++;
}
textIndex++;
}
return queryIndex === queryLower.length;
}
/**
* Calculate fuzzy match score (0-1, higher is better)
*/
export function fuzzyScore(query: string, text: string): number {
const queryLower = query.toLowerCase();
const textLower = text.toLowerCase();
if (textLower.includes(queryLower)) {
// Exact substring match gets high score
return 1 - (textLower.indexOf(queryLower) / textLower.length) * 0.3;
}
let score = 0;
let queryIndex = 0;
let textIndex = 0;
let consecutiveMatches = 0;
while (queryIndex < queryLower.length && textIndex < textLower.length) {
if (queryLower[queryIndex] === textLower[textIndex]) {
score += 1 + consecutiveMatches * 0.5; // Bonus for consecutive matches
consecutiveMatches++;
queryIndex++;
} else {
consecutiveMatches = 0;
}
textIndex++;
}
if (queryIndex < queryLower.length) {
return 0; // Didn't match all query characters
}
return score / (queryLower.length + textLower.length);
}
/**
* Search multiple fields in an object
*/
export function searchObject<T extends Record<string, any>>(
obj: T,
query: string,
fields: (keyof T)[]
): boolean {
const queryLower = query.toLowerCase();
return fields.some(field => {
const value = obj[field];
if (value == null) return false;
return String(value).toLowerCase().includes(queryLower);
});
}
/**
* Filter array by search query across multiple fields
*/
export function filterBySearch<T extends Record<string, any>>(
items: T[],
query: string,
fields: (keyof T)[]
): T[] {
if (!query.trim()) return items;
return items.filter(item => searchObject(item, query, fields));
}
/**
* Sort items by relevance to search query
*/
export function sortByRelevance<T extends Record<string, any>>(
items: T[],
query: string,
fields: (keyof T)[]
): T[] {
if (!query.trim()) return items;
return items
.map(item => {
const scores = fields.map(field => {
const value = item[field];
if (value == null) return 0;
return fuzzyScore(query, String(value));
});
const maxScore = Math.max(...scores);
return { item, score: maxScore };
})
.filter(({ score }) => score > 0)
.sort((a, b) => b.score - a.score)
.map(({ item }) => item);
}
/**
* Highlight matching text in a string
*/
export function highlightMatches(text: string, query: string): string {
if (!query.trim()) return text;
const regex = new RegExp(`(${escapeRegex(query)})`, "gi");
return text.replace(regex, "<mark>$1</mark>");
}
/**
* Escape special regex characters
*/
function escapeRegex(str: string): string {
return str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
/**
* Debounce function for search input
*/
export function debounce<T extends (...args: any[]) => any>(
func: T,
wait: number
): (...args: Parameters<T>) => void {
let timeout: ReturnType<typeof setTimeout> | null = null;
return function executedFunction(...args: Parameters<T>) {
const later = () => {
timeout = null;
func(...args);
};
if (timeout) clearTimeout(timeout);
timeout = setTimeout(later, wait);
};
}
/**
* Create a search index for faster lookups
*/
export class SearchIndex<T> {
private items: T[];
private index: Map<string, Set<number>>;
private getSearchableText: (item: T) => string;
constructor(items: T[], getSearchableText: (item: T) => string) {
this.items = items;
this.getSearchableText = getSearchableText;
this.index = new Map();
this.buildIndex();
}
private buildIndex() {
this.items.forEach((item, idx) => {
const text = this.getSearchableText(item).toLowerCase();
const words = text.split(/\s+/);
words.forEach(word => {
if (!this.index.has(word)) {
this.index.set(word, new Set());
}
this.index.get(word)!.add(idx);
});
});
}
search(query: string): T[] {
const words = query.toLowerCase().split(/\s+/).filter(w => w.length > 0);
if (words.length === 0) return this.items;
// Find items that match all words
const matchingSets = words.map(word => {
// Find all index entries that start with this word
const matches = new Set<number>();
for (const [indexWord, indices] of this.index.entries()) {
if (indexWord.startsWith(word)) {
indices.forEach(idx => matches.add(idx));
}
}
return matches;
});
// Intersect all sets
const intersection = matchingSets.reduce((acc, set) => {
const result = new Set<number>();
acc.forEach(idx => {
if (set.has(idx)) result.add(idx);
});
return result;
});
return Array.from(intersection).map(idx => this.items[idx]);
}
update(items: T[]) {
this.items = items;
this.index.clear();
this.buildIndex();
}
}
+12
View File
@@ -0,0 +1,12 @@
/// <reference types="vite/client" />
interface ImportMetaEnv {
readonly VITE_APP_URL: string;
readonly VITE_AUTH_BASE_URL: string;
readonly VITE_API_BASE_URL: string;
readonly VITE_DEMO_MODE: string;
}
interface ImportMeta {
readonly env: ImportMetaEnv;
}
+213
View File
@@ -0,0 +1,213 @@
module.exports = {
content: ["./index.html", "./src/**/*.{ts,tsx}"],
darkMode: "class",
theme: {
extend: {
colors: {
// Background layers
bg: {
main: "var(--bg-main)",
subtle: "var(--bg-subtle)",
elevated: "var(--bg-elevated)",
},
// Surface layers
surface: {
1: "var(--surface-1)",
2: "var(--surface-2)",
3: "var(--surface-3)",
},
// Border colors
border: {
DEFAULT: "var(--border)",
subtle: "var(--border-subtle)",
strong: "var(--border-strong)",
hover: "var(--border-hover)",
},
// Text colors
text: {
primary: "var(--text-primary)",
secondary: "var(--text-secondary)",
muted: "var(--text-muted)",
faint: "var(--text-faint)",
},
// Accent
accent: {
DEFAULT: "var(--accent)",
hover: "var(--accent-hover)",
active: "var(--accent-active)",
muted: "var(--accent-muted)",
subtle: "var(--accent-subtle)",
glow: "var(--accent-glow)",
},
// Status colors
success: {
DEFAULT: "var(--success)",
muted: "var(--success-muted)",
},
warning: {
DEFAULT: "var(--warning)",
muted: "var(--warning-muted)",
},
error: {
DEFAULT: "var(--error)",
muted: "var(--error-muted)",
},
info: {
DEFAULT: "var(--info)",
muted: "var(--info-muted)",
},
},
// Border radius
borderRadius: {
sm: "var(--radius-sm)",
DEFAULT: "var(--radius)",
lg: "var(--radius-lg)",
xl: "var(--radius-xl)",
},
// Box shadows
boxShadow: {
sm: "var(--shadow-sm)",
DEFAULT: "var(--shadow-md)",
lg: "var(--shadow-lg)",
elevated: "var(--shadow-elevated)",
glow: "var(--shadow-glow)",
},
// Font families
fontFamily: {
sans: "var(--font-sans)",
display: "var(--font-display)",
mono: "var(--font-mono)",
},
// Spacing
spacing: {
1: "var(--space-1)",
2: "var(--space-2)",
3: "var(--space-3)",
4: "var(--space-4)",
5: "var(--space-5)",
6: "var(--space-6)",
8: "var(--space-8)",
10: "var(--space-10)",
12: "var(--space-12)",
},
// Animation durations
transitionDuration: {
fast: "var(--duration-fast)",
normal: "var(--duration-normal)",
slow: "var(--duration-slow)",
},
// Animation timing functions
transitionTimingFunction: {
"ease-out": "var(--ease-out)",
"ease-in-out": "var(--ease-in-out)",
spring: "var(--ease-spring)",
},
// Animations
animation: {
"fade-in": "fadeIn var(--duration-normal) var(--ease-out)",
"slide-up": "slideUp var(--duration-normal) var(--ease-out)",
"slide-in-left": "slideInLeft var(--duration-normal) var(--ease-out)",
"slide-in-right": "slideInRight var(--duration-normal) var(--ease-out)",
"scale-in": "scaleIn var(--duration-fast) var(--ease-spring)",
"bounce-in": "bounceIn 0.5s var(--ease-spring)",
"float": "float 3s ease-in-out infinite",
"pulse-glow": "pulseGlow 2s ease-in-out infinite",
"gradient-shift": "gradientShift 3s ease infinite",
pulse: "pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite",
spin: "spin 0.8s linear infinite",
"spin-slow": "spin 8s linear infinite",
shimmer: "shimmer 2s ease-in-out infinite",
},
keyframes: {
fadeIn: {
from: { opacity: "0" },
to: { opacity: "1" },
},
slideUp: {
from: { opacity: "0", transform: "translateY(12px)" },
to: { opacity: "1", transform: "translateY(0)" },
},
slideInLeft: {
from: { opacity: "0", transform: "translateX(-20px)" },
to: { opacity: "1", transform: "translateX(0)" },
},
slideInRight: {
from: { opacity: "0", transform: "translateX(20px)" },
to: { opacity: "1", transform: "translateX(0)" },
},
scaleIn: {
from: { opacity: "0", transform: "scale(0.95)" },
to: { opacity: "1", transform: "scale(1)" },
},
bounceIn: {
"0%": { opacity: "0", transform: "scale(0.3)" },
"50%": { transform: "scale(1.05)" },
"70%": { transform: "scale(0.9)" },
"100%": { opacity: "1", transform: "scale(1)" },
},
float: {
"0%, 100%": { transform: "translateY(0)" },
"50%": { transform: "translateY(-10px)" },
},
pulseGlow: {
"0%, 100%": { boxShadow: "0 0 20px rgba(25, 163, 217, 0.2)" },
"50%": { boxShadow: "0 0 40px rgba(25, 163, 217, 0.4)" },
},
gradientShift: {
"0%, 100%": { backgroundPosition: "0% 50%" },
"50%": { backgroundPosition: "100% 50%" },
},
pulse: {
"0%, 100%": { opacity: "1" },
"50%": { opacity: "0.5" },
},
spin: {
to: { transform: "rotate(360deg)" },
},
shimmer: {
"0%": { backgroundPosition: "-1000px 0" },
"100%": { backgroundPosition: "1000px 0" },
},
},
// Typography
fontSize: {
"2xs": ["0.625rem", { lineHeight: "1rem" }],
xs: ["0.75rem", { lineHeight: "1rem" }],
sm: ["0.875rem", { lineHeight: "1.25rem" }],
base: ["1rem", { lineHeight: "1.5rem" }],
lg: ["1.125rem", { lineHeight: "1.5rem" }],
xl: ["1.25rem", { lineHeight: "1.75rem" }],
"2xl": ["1.5rem", { lineHeight: "2rem" }],
"3xl": ["2rem", { lineHeight: "2.5rem" }],
},
// Letter spacing
letterSpacing: {
tighter: "-0.02em",
tight: "-0.01em",
normal: "0",
wide: "0.05em",
wider: "0.1em",
widest: "0.14em",
},
// Z-index scale
zIndex: {
1: "1",
10: "10",
20: "20",
30: "30",
40: "40",
50: "50",
},
// Screens for responsive design
screens: {
sm: "640px",
md: "768px",
lg: "1024px",
xl: "1280px",
"2xl": "1536px",
},
},
},
plugins: [],
};
+9
View File
@@ -0,0 +1,9 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"jsx": "preserve",
"jsxImportSource": "solid-js",
"types": ["vite/client"]
},
"include": ["src"]
}
+18
View File
@@ -0,0 +1,18 @@
import { defineConfig } from "vite";
import solidPlugin from "vite-plugin-solid";
export default defineConfig({
plugins: [solidPlugin()],
server: {
host: "0.0.0.0",
port: Number(process.env.FRONTEND_PORT ?? 3000),
},
preview: {
host: "0.0.0.0",
port: Number(process.env.FRONTEND_PORT ?? 3000),
},
build: {
target: "esnext",
},
});
+18
View File
@@ -0,0 +1,18 @@
import { defineConfig } from 'vitest/config';
import solidPlugin from 'vite-plugin-solid';
import path from 'path';
export default defineConfig({
plugins: [solidPlugin()],
test: {
environment: 'jsdom',
globals: true,
setupFiles: ['./src/lib/__tests__/setup.ts'],
},
resolve: {
alias: {
'@': path.resolve(__dirname, './src'),
},
conditions: ['development', 'browser'],
},
});