mirror of
https://github.com/Dvorinka/Primora.git
synced 2026-06-03 20:13:01 +00:00
initiall commit
This commit is contained in:
@@ -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! 🚀**
|
||||
@@ -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;"]
|
||||
@@ -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
|
||||
@@ -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.
|
||||
@@ -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! 🚀**
|
||||
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
module.exports = {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
@@ -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()) ?? "";
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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();
|
||||
@@ -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!);
|
||||
@@ -0,0 +1,6 @@
|
||||
import { render } from "solid-js/web";
|
||||
|
||||
import App from "./App";
|
||||
import "./index.css";
|
||||
|
||||
render(() => <App />, document.getElementById("root")!);
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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";
|
||||
@@ -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>
|
||||
@@ -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); }
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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}`;
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
Vendored
+12
@@ -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;
|
||||
}
|
||||
@@ -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: [],
|
||||
};
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"jsx": "preserve",
|
||||
"jsxImportSource": "solid-js",
|
||||
"types": ["vite/client"]
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
@@ -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",
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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'],
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user