initiall commit

This commit is contained in:
Tomas Dvorak committed 2026-04-10 12:03:31 +02:00
commit 7ddfb1f52b
276 files changed
+37629

No files matched your search

+298
View File
@@ -0,0 +1,298 @@
import { Show, For } from "solid-js";
import { Button, Card, Input, Select, Badge, Table, EmptyState } from "../components";
import type { AuditLog } from "@primora/api-client";
interface AuditPageProps {
auditLogs?: AuditLog[];
auditSearch: string;
auditAction: string;
auditOffset: number;
auditPage?: { items: AuditLog[]; total: number; limit: number; offset: number };
onAuditSearchChange: (value: string) => void;
onAuditActionChange: (value: string) => void;
onAuditPageChange: (offset: number) => void;
onRefreshAudit: () => void;
formatDate: (date?: string | null) => string;
}
export function AuditPage(props: AuditPageProps) {
const getActionBadgeVariant = (action: string) => {
if (action.includes('create')) return 'success';
if (action.includes('delete')) return 'danger';
if (action.includes('update')) return 'primary';
return 'secondary';
};
const getActionIcon = (action: string) => {
if (action.includes('create')) {
return (
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4" />
</svg>
);
}
if (action.includes('delete')) {
return (
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
</svg>
);
}
if (action.includes('update')) {
return (
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
</svg>
);
}
return (
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
);
};
const formatResourceType = (type: string) => {
return type.split('_').map(word => word.charAt(0).toUpperCase() + word.slice(1)).join(' ');
};
return (
<div class="space-y-6">
{/* Header */}
<div class="flex items-center justify-between">
<div>
<h1 class="text-3xl font-bold text-gray-900">Audit Logs</h1>
<p class="text-gray-600 mt-1">Track all activities and changes in your project</p>
</div>
<Button variant="secondary" onClick={props.onRefreshAudit}>
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
</svg>
Refresh
</Button>
</div>
{/* Filters */}
<Card class="p-4">
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<Input
placeholder="Search by resource, actor, or details..."
value={props.auditSearch}
onInput={(e) => props.onAuditSearchChange(e.currentTarget.value)}
/>
<Select
value={props.auditAction}
onChange={(e) => props.onAuditActionChange(e.currentTarget.value)}
>
<option value="">All Actions</option>
<option value="create">Create</option>
<option value="update">Update</option>
<option value="delete">Delete</option>
<option value="upload">Upload</option>
<option value="download">Download</option>
</Select>
</div>
</Card>
{/* Stats Cards */}
<div class="grid grid-cols-1 md:grid-cols-4 gap-4">
<Card class="p-4">
<div class="flex items-center gap-3">
<div class="w-10 h-10 bg-blue-100 rounded-lg flex items-center justify-center">
<svg class="w-5 h-5 text-blue-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2" />
</svg>
</div>
<div>
<p class="text-2xl font-bold text-gray-900">{props.auditPage?.total ?? 0}</p>
<p class="text-sm text-gray-600">Total Events</p>
</div>
</div>
</Card>
<Card class="p-4">
<div class="flex items-center gap-3">
<div class="w-10 h-10 bg-green-100 rounded-lg flex items-center justify-center">
<svg class="w-5 h-5 text-green-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4" />
</svg>
</div>
<div>
<p class="text-2xl font-bold text-gray-900">
{props.auditLogs?.filter(log => log.action.includes('create')).length ?? 0}
</p>
<p class="text-sm text-gray-600">Creates</p>
</div>
</div>
</Card>
<Card class="p-4">
<div class="flex items-center gap-3">
<div class="w-10 h-10 bg-yellow-100 rounded-lg flex items-center justify-center">
<svg class="w-5 h-5 text-yellow-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
</svg>
</div>
<div>
<p class="text-2xl font-bold text-gray-900">
{props.auditLogs?.filter(log => log.action.includes('update')).length ?? 0}
</p>
<p class="text-sm text-gray-600">Updates</p>
</div>
</div>
</Card>
<Card class="p-4">
<div class="flex items-center gap-3">
<div class="w-10 h-10 bg-red-100 rounded-lg flex items-center justify-center">
<svg class="w-5 h-5 text-red-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
</svg>
</div>
<div>
<p class="text-2xl font-bold text-gray-900">
{props.auditLogs?.filter(log => log.action.includes('delete')).length ?? 0}
</p>
<p class="text-sm text-gray-600">Deletes</p>
</div>
</div>
</Card>
</div>
{/* Audit Logs Table */}
<Card>
<Show
when={(props.auditLogs?.length ?? 0) > 0}
fallback={
<div class="p-12">
<EmptyState
icon={
<svg class="w-16 h-16" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-3 7h3m-3 4h3m-6-4h.01M9 16h.01" />
</svg>
}
title="No audit logs"
description={props.auditSearch || props.auditAction ? "No logs match your filters" : "Activity will appear here as you use the platform"}
/>
</div>
}
>
<div class="overflow-x-auto">
<Table>
<thead>
<tr>
<th>Timestamp</th>
<th>Action</th>
<th>Resource</th>
<th>Actor</th>
<th>Details</th>
</tr>
</thead>
<tbody>
<For each={props.auditLogs}>
{(log) => (
<tr class="hover:bg-gray-50">
<td class="text-sm text-gray-600 whitespace-nowrap">
{props.formatDate(log.created_at)}
</td>
<td>
<div class="flex items-center gap-2">
<Badge variant={getActionBadgeVariant(log.action)} class="flex items-center gap-1">
{getActionIcon(log.action)}
{log.action}
</Badge>
</div>
</td>
<td>
<div>
<p class="font-medium text-gray-900">{formatResourceType(log.resource_type)}</p>
<Show when={log.resource_id}>
<code class="text-xs text-gray-500">{log.resource_id}</code>
</Show>
</div>
</td>
<td>
<div class="flex items-center gap-2">
<div class="w-8 h-8 rounded-full bg-gradient-to-br from-purple-500 to-pink-500 flex items-center justify-center text-white text-xs font-semibold">
{log.actor_name?.charAt(0).toUpperCase() ?? '?'}
</div>
<div>
<p class="text-sm font-medium text-gray-900">{log.actor_name}</p>
<p class="text-xs text-gray-500">{log.actor_email}</p>
</div>
</div>
</td>
<td>
<Show when={log.details}>
<details class="text-sm">
<summary class="cursor-pointer text-blue-600 hover:text-blue-700">
View details
</summary>
<pre class="mt-2 p-2 bg-gray-50 rounded text-xs overflow-x-auto">
{JSON.stringify(log.details, null, 2)}
</pre>
</details>
</Show>
</td>
</tr>
)}
</For>
</tbody>
</Table>
</div>
{/* Pagination */}
<Show when={props.auditPage && props.auditPage.total > props.auditPage.limit}>
<div class="p-4 border-t border-gray-200 flex items-center justify-between">
<p class="text-sm text-gray-600">
Showing {props.auditPage!.offset + 1} to {Math.min(props.auditPage!.offset + props.auditPage!.limit, props.auditPage!.total)} of {props.auditPage!.total}
</p>
<div class="flex gap-2">
<Button
variant="ghost"
size="sm"
disabled={props.auditPage!.offset === 0}
onClick={() => props.onAuditPageChange(Math.max(0, props.auditPage!.offset - props.auditPage!.limit))}
>
Previous
</Button>
<Button
variant="ghost"
size="sm"
disabled={props.auditPage!.offset + props.auditPage!.limit >= props.auditPage!.total}
onClick={() => props.onAuditPageChange(props.auditPage!.offset + props.auditPage!.limit)}
>
Next
</Button>
</div>
</div>
</Show>
</Show>
</Card>
{/* Export Section */}
<Card class="p-6 bg-gradient-to-r from-purple-50 to-blue-50 border-purple-200">
<div class="flex items-center justify-between">
<div>
<h3 class="text-lg font-semibold mb-1">Export Audit Logs</h3>
<p class="text-sm text-gray-600">Download audit logs for compliance and analysis</p>
</div>
<div class="flex gap-2">
<Button variant="outline">
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 10v6m0 0l-3-3m3 3l3-3m2 8H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
</svg>
Export CSV
</Button>
<Button variant="outline">
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 10v6m0 0l-3-3m3 3l3-3m2 8H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
</svg>
Export JSON
</Button>
</div>
</div>
</Card>
</div>
);
}
+286
View File
@@ -0,0 +1,286 @@
import { Show, For, createSignal, createMemo } from "solid-js";
import {
Button,
Card,
Input,
Textarea,
Badge,
EmptyState,
Message,
Modal,
Table,
DataTable
} from "../components";
import type { Collection, Document } from "@primora/api-client";
interface CollectionsPageProps {
collections: Collection[];
documents: Document[];
selectedCollectionID?: string;
collectionInput: { name: string; slug: string; description: string };
collectionMessage: string;
collectionPending: boolean;
documentPending: boolean;
canUpdate: boolean;
onCollectionInputChange: (field: string, value: string) => void;
onCreateCollection: (e: SubmitEvent) => void;
onDeleteCollection: (id: string) => void;
onSelectCollection: (id: string) => void;
onCreateDocument: (data: any) => void;
onUpdateDocument: (id: string, data: any) => void;
onDeleteDocument: (id: string) => void;
formatDate: (date?: string | null) => string;
}
export function CollectionsPage(props: CollectionsPageProps) {
const [showCreateCollectionModal, setShowCreateCollectionModal] = createSignal(false);
const [showDocumentModal, setShowDocumentModal] = createSignal(false);
const [editingDocument, setEditingDocument] = createSignal<Document | null>(null);
const [documentData, setDocumentData] = createSignal("");
const activeCollection = createMemo(() =>
props.collections.find(c => c.id === props.selectedCollectionID)
);
const handleCreateCollection = (e: SubmitEvent) => {
props.onCreateCollection(e);
setShowCreateCollectionModal(false);
};
const handleOpenDocumentModal = (doc: Document | null = null) => {
setEditingDocument(doc);
setDocumentData(doc ? JSON.stringify(doc.data, null, 2) : "{\n \n}");
setShowDocumentModal(true);
};
const handleSaveDocument = (e: SubmitEvent) => {
e.preventDefault();
try {
const data = JSON.parse(documentData());
if (editingDocument()) {
props.onUpdateDocument(editingDocument()!.id, data);
} else {
props.onCreateDocument(data);
}
setShowDocumentModal(false);
} catch (err) {
alert("Invalid JSON data");
}
};
return (
<div class="flex flex-col md:flex-row gap-6 h-[calc(100vh-12rem)]">
{/* Sidebar: Collections List */}
<div class="w-full md:w-64 flex flex-col gap-4">
<div class="flex items-center justify-between">
<h2 class="text-xl font-bold">Collections</h2>
<Button size="sm" onClick={() => setShowCreateCollectionModal(true)}>
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4" />
</svg>
</Button>
</div>
<Card class="flex-1 overflow-y-auto p-2">
<Show
when={props.collections.length > 0}
fallback={<p class="text-sm text-gray-500 p-4 text-center">No collections</p>}
>
<div class="space-y-1">
<For each={props.collections}>
{(collection) => (
<button
onClick={() => props.onSelectCollection(collection.id)}
class={`w-full text-left px-3 py-2 rounded-md text-sm transition-colors ${
props.selectedCollectionID === collection.id
? 'bg-blue-50 text-blue-700 font-medium'
: 'text-gray-600 hover:bg-gray-50'
}`}
>
<div class="flex items-center justify-between">
<span>{collection.name}</span>
<Show when={props.selectedCollectionID === collection.id}>
<div class="w-1.5 h-1.5 rounded-full bg-blue-600" />
</Show>
</div>
</button>
)}
</For>
</div>
</Show>
</Card>
</div>
{/* Main Content: Documents Table */}
<div class="flex-1 flex flex-col gap-4 min-w-0">
<Show
when={activeCollection()}
fallback={
<Card class="flex-1 flex items-center justify-center p-8">
<EmptyState
title="No collection selected"
description="Select a collection from the sidebar to view its documents"
icon={
<svg class="w-12 h-12 text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M4 7v10c0 2.21 3.582 4 8 4s8-1.79 8-4V7M4 7c0 2.21 3.582 4 8 4s8-1.79 8-4M4 7c0-2.21 3.582-4 8-4s8 1.79 8 4m0 5c0 2.21-3.582 4-8 4s-8-1.79-8-4" />
</svg>
}
/>
</Card>
}
>
<div class="flex items-center justify-between">
<div>
<h2 class="text-2xl font-bold text-gray-900">{activeCollection()?.name}</h2>
<p class="text-sm text-gray-500">{activeCollection()?.slug}</p>
</div>
<div class="flex gap-2">
<Button variant="ghost" size="sm" onClick={() => props.onDeleteCollection(activeCollection()!.id)}>
Delete Collection
</Button>
<Button onClick={() => handleOpenDocumentModal()}>
Add Document
</Button>
</div>
</div>
<Card class="flex-1 overflow-hidden flex flex-col">
<Show
when={props.documents.length > 0}
fallback={
<div class="flex-1 flex items-center justify-center p-8">
<EmptyState
title="No documents yet"
description="This collection is empty. Add your first document to get started."
action={<Button onClick={() => handleOpenDocumentModal()}>Add Document</Button>}
/>
</div>
}
>
<div class="overflow-auto">
<Table>
<thead>
<tr>
<th class="w-1/3">ID</th>
<th>Data</th>
<th class="w-48">Created At</th>
<th class="w-24 text-right">Actions</th>
</tr>
</thead>
<tbody>
<For each={props.documents}>
{(doc) => (
<tr class="hover:bg-gray-50">
<td class="font-mono text-xs text-gray-500">{doc.id}</td>
<td>
<div class="max-w-md truncate text-sm">
{JSON.stringify(doc.data)}
</div>
</td>
<td class="text-sm text-gray-500">{props.formatDate(doc.created_at)}</td>
<td class="text-right">
<div class="flex justify-end gap-1">
<button
onClick={() => handleOpenDocumentModal(doc)}
class="p-1 text-gray-400 hover:text-blue-600 transition-colors"
>
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15.232 5.232l3.536 3.536m-2.036-5.036a2.5 2.5 0 113.536 3.536L6.5 21.036H3v-3.572L16.732 3.732z" />
</svg>
</button>
<button
onClick={() => props.onDeleteDocument(doc.id)}
class="p-1 text-gray-400 hover:text-red-600 transition-colors"
>
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
</svg>
</button>
</div>
</td>
</tr>
)}
</For>
</tbody>
</Table>
</div>
</Show>
</Card>
</Show>
</div>
{/* Create Collection Modal */}
<Modal
open={showCreateCollectionModal()}
onClose={() => setShowCreateCollectionModal(false)}
title="Create New Collection"
>
<form class="space-y-4" onSubmit={handleCreateCollection}>
<Input
label="Collection Name"
placeholder="Users, Products, etc."
value={props.collectionInput.name}
onInput={(e) => {
const name = e.currentTarget.value;
props.onCollectionInputChange('name', name);
const slug = name.toLowerCase().trim().replace(/[^a-z0-9\s-]/g, '').replace(/\s+/g, '-').replace(/-+/g, '-');
props.onCollectionInputChange('slug', slug);
}}
required
/>
<Input
label="Collection Slug"
placeholder="users"
value={props.collectionInput.slug}
onInput={(e) => props.onCollectionInputChange('slug', e.currentTarget.value)}
required
/>
<Textarea
label="Description"
placeholder="What's in this collection?"
value={props.collectionInput.description}
onInput={(e) => props.onCollectionInputChange('description', e.currentTarget.value)}
rows={3}
/>
<div class="flex gap-3 justify-end pt-4">
<Button type="button" variant="ghost" onClick={() => setShowCreateCollectionModal(false)}>
Cancel
</Button>
<Button type="submit" variant="primary" disabled={props.collectionPending}>
{props.collectionPending ? "Creating..." : "Create Collection"}
</Button>
</div>
</form>
</Modal>
{/* Document Editor Modal */}
<Modal
open={showDocumentModal()}
onClose={() => setShowDocumentModal(false)}
title={editingDocument() ? "Edit Document" : "New Document"}
>
<form class="space-y-4" onSubmit={handleSaveDocument}>
<div class="space-y-2">
<label class="block text-sm font-medium text-gray-700">JSON Data</label>
<div class="font-mono text-sm border rounded-md overflow-hidden bg-gray-50">
<textarea
value={documentData()}
onInput={(e) => setDocumentData(e.currentTarget.value)}
class="w-full h-64 p-4 bg-transparent outline-none focus:ring-2 focus:ring-blue-500"
spellcheck={false}
/>
</div>
</div>
<div class="flex gap-3 justify-end pt-4">
<Button type="button" variant="ghost" onClick={() => setShowDocumentModal(false)}>
Cancel
</Button>
<Button type="submit" variant="primary" disabled={props.documentPending}>
{props.documentPending ? "Saving..." : "Save Document"}
</Button>
</div>
</form>
</Modal>
</div>
);
}
@@ -0,0 +1,223 @@
import { For } from "solid-js";
import { Logo } from "../components/Logo";
import { Button } from "../components/Button";
import { Card, CardHeader, StatCard } from "../components/Card";
import { Input, Textarea, Select } from "../components/Input";
import { Badge, StatusBadge } from "../components/Badge";
import { Message, Loading, Skeleton } from "../components/Message";
import { Table } from "../components/Table";
export function EnhancementsShowcase() {
const sampleData = [
{ id: 1, name: "Project Alpha", status: "active", users: 42 },
{ id: 2, name: "Project Beta", status: "pending", users: 18 },
{ id: 3, name: "Project Gamma", status: "completed", users: 156 },
];
const columns = [
{ key: "name", header: "Project Name" },
{ key: "status", header: "Status", render: (val: string) => <StatusBadge status={val as any} /> },
{ key: "users", header: "Users", align: "right" as const },
];
return (
<div class="min-h-screen bg-bg-main p-8">
<div class="max-w-7xl mx-auto space-y-12">
{/* Header */}
<div class="text-center space-y-4 animate-fade-in">
<div class="flex justify-center mb-6">
<Logo size="lg" animated />
</div>
<h1 class="text-5xl font-display font-extrabold">
Enhanced Design System
</h1>
<p class="text-lg text-text-secondary max-w-2xl mx-auto">
A distinctive, production-grade UI system with sophisticated micro-interactions,
refined typography, and purposeful animations.
</p>
</div>
{/* Typography Section */}
<section class="space-y-6 animate-slide-up stagger-1">
<div class="section-eyebrow">Typography</div>
<h2 class="section-title">Distinctive Font System</h2>
<div class="grid grid-cols-1 md:grid-cols-3 gap-6">
<Card class="card-premium">
<h3 class="font-display text-2xl font-bold mb-2">Syne Display</h3>
<p class="text-text-secondary text-sm">
Bold, geometric display font for headings and brand elements
</p>
</Card>
<Card class="card-premium">
<h3 class="font-sans text-2xl font-semibold mb-2">DM Sans Body</h3>
<p class="text-text-secondary text-sm">
Clean, readable sans-serif for body text and UI elements
</p>
</Card>
<Card class="card-premium">
<h3 class="font-mono text-xl font-medium mb-2">JetBrains Mono</h3>
<p class="text-text-secondary text-sm">
Professional monospace for code and technical content
</p>
</Card>
</div>
</section>
{/* Buttons Section */}
<section class="space-y-6 animate-slide-up stagger-2">
<div class="section-eyebrow">Interactive Elements</div>
<h2 class="section-title">Enhanced Buttons</h2>
<div class="flex flex-wrap gap-4">
<Button variant="primary" class="btn-glow">
Primary Action
</Button>
<Button variant="secondary">
Secondary Action
</Button>
<Button variant="ghost">
Ghost Button
</Button>
<Button variant="danger">
Danger Action
</Button>
<Button variant="primary" loading>
Loading...
</Button>
</div>
</section>
{/* Cards Section */}
<section class="space-y-6 animate-slide-up stagger-3">
<div class="section-eyebrow">Data Display</div>
<h2 class="section-title">Stat Cards with Animations</h2>
<div class="grid grid-cols-1 md:grid-cols-4 gap-6">
<div class="stat-card-enhanced">
<p class="stat-label">Total Users</p>
<p class="stat-value-animated">2,847</p>
<p class="text-xs text-success mt-2">↑ 12.5% from last month</p>
</div>
<div class="stat-card-enhanced">
<p class="stat-label">Active Projects</p>
<p class="stat-value-animated">42</p>
<p class="text-xs text-accent mt-2">→ Stable</p>
</div>
<div class="stat-card-enhanced">
<p class="stat-label">Storage Used</p>
<p class="stat-value-animated">156 GB</p>
<p class="text-xs text-warning mt-2">↑ 8.2% from last month</p>
</div>
<div class="stat-card-enhanced">
<p class="stat-label">API Calls</p>
<p class="stat-value-animated">1.2M</p>
<p class="text-xs text-success mt-2">↑ 24.1% from last month</p>
</div>
</div>
</section>
{/* Forms Section */}
<section class="space-y-6 animate-slide-up stagger-4">
<div class="section-eyebrow">Form Elements</div>
<h2 class="section-title">Enhanced Inputs</h2>
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<Card class="card-premium">
<div class="space-y-4">
<div class="input-enhanced">
<Input label="Project Name" placeholder="Enter project name..." />
</div>
<div class="input-enhanced">
<Select label="Project Type" options={[
{ value: "web", label: "Web Application" },
{ value: "mobile", label: "Mobile App" },
{ value: "api", label: "API Service" },
]} />
</div>
<div class="input-enhanced">
<Textarea label="Description" placeholder="Describe your project..." />
</div>
</div>
</Card>
<Card class="card-premium">
<CardHeader
eyebrow="Form States"
title="Interactive Feedback"
description="Hover and focus states with smooth animations"
/>
<div class="space-y-4 mt-4">
<Badge variant="primary" class="badge-animated">Active</Badge>
<Badge variant="success" class="badge-animated">Completed</Badge>
<Badge variant="warning" class="badge-animated">Pending</Badge>
<Badge variant="error" class="badge-animated">Error</Badge>
</div>
</Card>
</div>
</section>
{/* Messages Section */}
<section class="space-y-6 animate-slide-up stagger-5">
<div class="section-eyebrow">Feedback</div>
<h2 class="section-title">Messages & Alerts</h2>
<div class="space-y-4">
<Message variant="success" title="Success!">
Your project has been created successfully.
</Message>
<Message variant="info" title="Information">
New features are available in the latest update.
</Message>
<Message variant="warning" title="Warning">
Your storage is almost full. Consider upgrading your plan.
</Message>
<Message variant="error" title="Error">
Failed to connect to the server. Please try again.
</Message>
</div>
</section>
{/* Table Section */}
<section class="space-y-6 animate-slide-up stagger-6">
<div class="section-eyebrow">Data Tables</div>
<h2 class="section-title">Enhanced Table Component</h2>
<Card class="card-premium">
<div class="table-enhanced">
<Table
columns={columns}
data={sampleData}
rowKey={(row) => row.id}
/>
</div>
</Card>
</section>
{/* Loading States */}
<section class="space-y-6 animate-slide-up">
<div class="section-eyebrow">Loading States</div>
<h2 class="section-title">Skeleton & Spinners</h2>
<div class="grid grid-cols-1 md:grid-cols-3 gap-6">
<Card>
<Skeleton width="60%" height="20px" class="mb-3" />
<Skeleton width="100%" height="14px" class="mb-2" />
<Skeleton width="100%" height="14px" class="mb-2" />
<Skeleton width="80%" height="14px" />
</Card>
<Card class="flex items-center justify-center">
<Loading text="Loading data..." size="lg" />
</Card>
<Card class="flex items-center justify-center">
<div class="loading-dots">
<span></span>
<span></span>
<span></span>
</div>
</Card>
</div>
</section>
{/* Footer */}
<footer class="text-center py-12 border-t border-border">
<p class="text-text-muted text-sm">
PRIMORA Enhanced Design System — Production-grade UI components
</p>
</footer>
</div>
</div>
);
}
+386
View File
@@ -0,0 +1,386 @@
import { Show, For, createSignal } from "solid-js";
import { Button, Card, Input, Select, Badge, Table, EmptyState, Message, Modal, Tabs, TabPanel } from "../components";
import type { OrganizationMember, OrganizationInvitation, ProjectMember } from "@primora/api-client";
interface MembersPageProps {
organizationMembers?: OrganizationMember[];
organizationInvitations?: OrganizationInvitation[];
projectMembers?: ProjectMember[];
invitationInput: {
email: string;
orgRole: string;
attachProject: boolean;
projectRole: string;
};
inviteMessage: string;
memberMessage: string;
invitationPending: boolean;
membersPending: boolean;
canManageMembers: boolean;
hasActiveProject: boolean;
onInvitationInputChange: (field: string, value: any) => void;
onSendInvitation: (e: SubmitEvent) => void;
onRevokeInvitation: (id: string) => void;
onRemoveMember: (id: string, type: 'org' | 'project') => void;
onUpdateMemberRole: (id: string, role: string, type: 'org' | 'project') => void;
}
export function MembersPage(props: MembersPageProps) {
const [showInviteModal, setShowInviteModal] = createSignal(false);
const [activeTab, setActiveTab] = createSignal<'organization' | 'project'>('organization');
const [searchQuery, setSearchQuery] = createSignal("");
const filteredOrgMembers = () => {
const query = searchQuery().toLowerCase();
if (!query || !props.organizationMembers) return props.organizationMembers || [];
return props.organizationMembers.filter(m =>
m.user_name?.toLowerCase().includes(query) ||
m.user_email?.toLowerCase().includes(query)
);
};
const filteredProjectMembers = () => {
const query = searchQuery().toLowerCase();
if (!query || !props.projectMembers) return props.projectMembers || [];
return props.projectMembers.filter(m =>
m.user_name?.toLowerCase().includes(query) ||
m.user_email?.toLowerCase().includes(query)
);
};
const handleInviteSubmit = (e: SubmitEvent) => {
props.onSendInvitation(e);
setShowInviteModal(false);
};
const getRoleBadgeVariant = (role: string) => {
switch (role) {
case 'owner': return 'primary';
case 'admin': return 'success';
default: return 'secondary';
}
};
return (
<div class="space-y-6">
{/* Header */}
<div class="flex items-center justify-between">
<div>
<h1 class="text-3xl font-bold text-gray-900">Team Members</h1>
<p class="text-gray-600 mt-1">Manage organization and project access</p>
</div>
<Show when={props.canManageMembers}>
<Button onClick={() => setShowInviteModal(true)}>
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M18 9v3m0 0v3m0-3h3m-3 0h-3m-2-5a4 4 0 11-8 0 4 4 0 018 0zM3 20a6 6 0 0112 0v1H3v-1z" />
</svg>
Invite Member
</Button>
</Show>
</div>
{/* Tabs */}
<Tabs
tabs={[
{ id: 'organization', label: 'Organization Members' },
{ id: 'project', label: 'Project Members', disabled: !props.hasActiveProject },
]}
activeTab={activeTab()}
onChange={(id) => setActiveTab(id as 'organization' | 'project')}
/>
{/* Search Bar */}
<Card class="p-4">
<Input
placeholder="Search members by name or email..."
value={searchQuery()}
onInput={(e) => setSearchQuery(e.currentTarget.value)}
class="w-full"
/>
</Card>
{/* Organization Members Tab */}
<Show when={activeTab() === 'organization'}>
<div class="space-y-6">
{/* Pending Invitations */}
<Show when={(props.organizationInvitations?.length ?? 0) > 0}>
<Card class="p-6">
<h2 class="text-lg font-semibold mb-4 flex items-center gap-2">
<svg class="w-5 h-5 text-yellow-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
Pending Invitations
</h2>
<div class="space-y-3">
<For each={props.organizationInvitations}>
{(invitation) => (
<div class="flex items-center justify-between p-4 bg-yellow-50 border border-yellow-200 rounded-lg">
<div class="flex-1">
<p class="font-medium text-gray-900">{invitation.email}</p>
<div class="flex items-center gap-2 mt-1">
<Badge variant="secondary">{invitation.org_role}</Badge>
<Show when={invitation.project_id}>
<Badge variant="secondary">Project: {invitation.project_role}</Badge>
</Show>
<span class="text-xs text-gray-500">
Invited {new Date(invitation.created_at).toLocaleDateString()}
</span>
</div>
</div>
<Show when={props.canManageMembers}>
<Button
variant="ghost"
size="sm"
onClick={() => props.onRevokeInvitation(invitation.id)}
disabled={props.invitationPending}
>
Revoke
</Button>
</Show>
</div>
)}
</For>
</div>
</Card>
</Show>
{/* Members List */}
<Card>
<Show
when={filteredOrgMembers().length > 0}
fallback={
<div class="p-12">
<EmptyState
icon={
<svg class="w-16 h-16" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197M13 7a4 4 0 11-8 0 4 4 0 018 0z" />
</svg>
}
title="No members found"
description={searchQuery() ? "Try adjusting your search" : "Invite team members to collaborate"}
/>
</div>
}
>
<Table>
<thead>
<tr>
<th>Member</th>
<th>Role</th>
<th>Joined</th>
<th class="text-right">Actions</th>
</tr>
</thead>
<tbody>
<For each={filteredOrgMembers()}>
{(member) => (
<tr>
<td>
<div class="flex items-center gap-3">
<div class="w-10 h-10 rounded-full bg-gradient-to-br from-blue-500 to-purple-500 flex items-center justify-center text-white font-semibold">
{member.user_name?.charAt(0).toUpperCase() ?? '?'}
</div>
<div>
<p class="font-medium text-gray-900">{member.user_name}</p>
<p class="text-sm text-gray-600">{member.user_email}</p>
</div>
</div>
</td>
<td>
<Show
when={props.canManageMembers && member.role !== 'owner'}
fallback={<Badge variant={getRoleBadgeVariant(member.role)}>{member.role}</Badge>}
>
<Select
value={member.role}
onChange={(e) => props.onUpdateMemberRole(member.user_id, e.currentTarget.value, 'org')}
disabled={props.membersPending}
class="w-32"
>
<option value="member">Member</option>
<option value="admin">Admin</option>
<option value="owner">Owner</option>
</Select>
</Show>
</td>
<td class="text-sm text-gray-600">
{new Date(member.created_at).toLocaleDateString()}
</td>
<td class="text-right">
<Show when={props.canManageMembers && member.role !== 'owner'}>
<Button
variant="ghost"
size="sm"
onClick={() => props.onRemoveMember(member.user_id, 'org')}
disabled={props.membersPending}
>
Remove
</Button>
</Show>
</td>
</tr>
)}
</For>
</tbody>
</Table>
</Show>
</Card>
</div>
</Show>
{/* Project Members Tab */}
<Show when={activeTab() === 'project'}>
<Card>
<Show
when={filteredProjectMembers().length > 0}
fallback={
<div class="p-12">
<EmptyState
icon={
<svg class="w-16 h-16" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197M13 7a4 4 0 11-8 0 4 4 0 018 0z" />
</svg>
}
title="No project members"
description="Add members to this project to collaborate"
/>
</div>
}
>
<Table>
<thead>
<tr>
<th>Member</th>
<th>Role</th>
<th>Added</th>
<th class="text-right">Actions</th>
</tr>
</thead>
<tbody>
<For each={filteredProjectMembers()}>
{(member) => (
<tr>
<td>
<div class="flex items-center gap-3">
<div class="w-10 h-10 rounded-full bg-gradient-to-br from-green-500 to-teal-500 flex items-center justify-center text-white font-semibold">
{member.user_name?.charAt(0).toUpperCase() ?? '?'}
</div>
<div>
<p class="font-medium text-gray-900">{member.user_name}</p>
<p class="text-sm text-gray-600">{member.user_email}</p>
</div>
</div>
</td>
<td>
<Show
when={props.canManageMembers}
fallback={<Badge variant={getRoleBadgeVariant(member.role)}>{member.role}</Badge>}
>
<Select
value={member.role}
onChange={(e) => props.onUpdateMemberRole(member.user_id, e.currentTarget.value, 'project')}
disabled={props.membersPending}
class="w-32"
>
<option value="viewer">Viewer</option>
<option value="developer">Developer</option>
<option value="admin">Admin</option>
</Select>
</Show>
</td>
<td class="text-sm text-gray-600">
{new Date(member.created_at).toLocaleDateString()}
</td>
<td class="text-right">
<Show when={props.canManageMembers}>
<Button
variant="ghost"
size="sm"
onClick={() => props.onRemoveMember(member.user_id, 'project')}
disabled={props.membersPending}
>
Remove
</Button>
</Show>
</td>
</tr>
)}
</For>
</tbody>
</Table>
</Show>
</Card>
</Show>
<Show when={props.memberMessage}>
<Message variant="neutral">{props.memberMessage}</Message>
</Show>
{/* Invite Modal */}
<Modal open={showInviteModal()} onClose={() => setShowInviteModal(false)} title="Invite Team Member">
<form class="space-y-4" onSubmit={handleInviteSubmit}>
<Input
label="Email Address"
type="email"
placeholder="[email protected]"
value={props.invitationInput.email}
onInput={(e) => props.onInvitationInputChange('email', e.currentTarget.value)}
disabled={props.invitationPending}
required
/>
<Select
label="Organization Role"
value={props.invitationInput.orgRole}
onChange={(e) => props.onInvitationInputChange('orgRole', e.currentTarget.value)}
disabled={props.invitationPending}
>
<option value="member">Member</option>
<option value="admin">Admin</option>
</Select>
<Show when={props.hasActiveProject}>
<div class="flex items-center gap-2">
<input
type="checkbox"
id="attachProject"
checked={props.invitationInput.attachProject}
onChange={(e) => props.onInvitationInputChange('attachProject', e.currentTarget.checked)}
disabled={props.invitationPending}
class="rounded border-gray-300"
/>
<label for="attachProject" class="text-sm text-gray-700">
Add to current project
</label>
</div>
<Show when={props.invitationInput.attachProject}>
<Select
label="Project Role"
value={props.invitationInput.projectRole}
onChange={(e) => props.onInvitationInputChange('projectRole', e.currentTarget.value)}
disabled={props.invitationPending}
>
<option value="viewer">Viewer</option>
<option value="developer">Developer</option>
<option value="admin">Admin</option>
</Select>
</Show>
</Show>
<div class="flex gap-3 justify-end pt-4">
<Button type="button" variant="ghost" onClick={() => setShowInviteModal(false)}>
Cancel
</Button>
<Button type="submit" variant="primary" disabled={props.invitationPending}>
{props.invitationPending ? "Sending..." : "Send Invitation"}
</Button>
</div>
</form>
<Show when={props.inviteMessage}>
<Message variant="neutral" class="mt-4">{props.inviteMessage}</Message>
</Show>
</Modal>
</div>
);
}
+237
View File
@@ -0,0 +1,237 @@
import { Show, For, createSignal } from "solid-js";
import { Button, Card, Input, Textarea, Badge, EmptyState, Message, Modal } from "../components";
import type { ProjectSummary } from "@primora/api-client";
interface ProjectsPageProps {
projects: ProjectSummary[];
selectedProjectID?: string;
projectInput: { name: string; slug: string; description: string };
projectEditInput: { name: string; slug: string; description: string };
projectMessage: string;
projectPending: boolean;
canUpdateProject: boolean;
onProjectInputChange: (field: string, value: string) => void;
onProjectEditInputChange: (field: string, value: string) => void;
onCreateProject: (e: SubmitEvent) => void;
onUpdateProject: (e: SubmitEvent) => void;
onDeleteProject: () => void;
onSelectProject: (id: string) => void;
onNavigateToDashboard: () => void;
}
export function ProjectsPage(props: ProjectsPageProps) {
const [showCreateModal, setShowCreateModal] = createSignal(false);
const [searchQuery, setSearchQuery] = createSignal("");
const filteredProjects = () => {
const query = searchQuery().toLowerCase();
if (!query) return props.projects;
return props.projects.filter(p =>
p.name.toLowerCase().includes(query) ||
p.slug.toLowerCase().includes(query) ||
p.description?.toLowerCase().includes(query)
);
};
const handleCreateSubmit = (e: SubmitEvent) => {
props.onCreateProject(e);
setShowCreateModal(false);
};
return (
<div class="space-y-6">
{/* Header */}
<div class="flex items-center justify-between">
<div>
<h1 class="text-3xl font-bold text-gray-900">Projects</h1>
<p class="text-gray-600 mt-1">Manage your organization's projects</p>
</div>
<Button onClick={() => setShowCreateModal(true)}>
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4" />
</svg>
New Project
</Button>
</div>
{/* Search Bar */}
<Card class="p-4">
<Input
placeholder="Search projects by name, slug, or description..."
value={searchQuery()}
onInput={(e) => setSearchQuery(e.currentTarget.value)}
class="w-full"
/>
</Card>
{/* Projects Grid */}
<Show
when={filteredProjects().length > 0}
fallback={
<EmptyState
icon={
<svg class="w-16 h-16" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10" />
</svg>
}
title="No projects found"
description={searchQuery() ? "Try adjusting your search" : "Create your first project to get started"}
action={
<Show when={!searchQuery()}>
<Button onClick={() => setShowCreateModal(true)}>Create Project</Button>
</Show>
}
/>
}
>
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
<For each={filteredProjects()}>
{(project) => (
<Card
class={`p-6 cursor-pointer transition-all hover:shadow-lg hover:border-blue-500 ${
props.selectedProjectID === project.id ? 'border-blue-500 bg-blue-50' : ''
}`}
onClick={() => {
props.onSelectProject(project.id);
props.onNavigateToDashboard();
}}
>
<div class="flex items-start justify-between mb-4">
<div class="flex-1">
<h3 class="text-lg font-semibold text-gray-900 mb-1">{project.name}</h3>
<Badge variant="secondary" class="text-xs">{project.slug}</Badge>
</div>
<Show when={project.membershipRole}>
<Badge variant={project.membershipRole === 'admin' ? 'primary' : 'secondary'}>
{project.membershipRole}
</Badge>
</Show>
</div>
<Show when={project.description}>
<p class="text-sm text-gray-600 mb-4 line-clamp-2">{project.description}</p>
</Show>
<div class="flex items-center justify-between pt-4 border-t border-gray-200">
<button
onClick={(e) => {
e.stopPropagation();
props.onSelectProject(project.id);
props.onNavigateToDashboard();
}}
class="text-sm text-blue-600 hover:text-blue-700 font-medium"
>
View Dashboard →
</button>
<Show when={project.membershipRole === 'admin'}>
<button
onClick={(e) => {
e.stopPropagation();
props.onSelectProject(project.id);
}}
class="text-sm text-gray-600 hover:text-gray-700"
>
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" />
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
</button>
</Show>
</div>
</Card>
)}
</For>
</div>
</Show>
{/* Selected Project Settings */}
<Show when={props.selectedProjectID && props.canUpdateProject}>
<Card class="p-6">
<h2 class="text-xl font-semibold mb-4">Project Settings</h2>
<form class="space-y-4" onSubmit={props.onUpdateProject}>
<Input
label="Project Name"
placeholder="Enter project name"
value={props.projectEditInput.name}
onInput={(e) => props.onProjectEditInputChange('name', e.currentTarget.value)}
disabled={props.projectPending}
/>
<Input
label="Project Slug"
placeholder="project-slug"
value={props.projectEditInput.slug}
onInput={(e) => props.onProjectEditInputChange('slug', e.currentTarget.value)}
disabled={props.projectPending}
/>
<Textarea
label="Description"
placeholder="Describe your project..."
value={props.projectEditInput.description}
onInput={(e) => props.onProjectEditInputChange('description', e.currentTarget.value)}
disabled={props.projectPending}
rows={3}
/>
<div class="flex gap-3">
<Button type="submit" variant="primary" disabled={props.projectPending}>
{props.projectPending ? "Updating..." : "Update Project"}
</Button>
<Button type="button" variant="danger" onClick={props.onDeleteProject} disabled={props.projectPending}>
Delete Project
</Button>
</div>
</form>
<Show when={props.projectMessage}>
<Message variant="neutral" class="mt-4">{props.projectMessage}</Message>
</Show>
</Card>
</Show>
{/* Create Project Modal */}
<Modal open={showCreateModal()} onClose={() => setShowCreateModal(false)} title="Create New Project">
<form class="space-y-4" onSubmit={handleCreateSubmit}>
<Input
label="Project Name"
placeholder="My Awesome Project"
value={props.projectInput.name}
onInput={(e) => {
const name = e.currentTarget.value;
props.onProjectInputChange('name', name);
// Auto-generate slug
const slug = name.toLowerCase().trim().replace(/[^a-z0-9\s-]/g, '').replace(/\s+/g, '-').replace(/-+/g, '-');
props.onProjectInputChange('slug', slug);
}}
disabled={props.projectPending}
required
/>
<Input
label="Project Slug"
placeholder="my-awesome-project"
value={props.projectInput.slug}
onInput={(e) => props.onProjectInputChange('slug', e.currentTarget.value)}
disabled={props.projectPending}
required
/>
<Textarea
label="Description (Optional)"
placeholder="What is this project about?"
value={props.projectInput.description}
onInput={(e) => props.onProjectInputChange('description', e.currentTarget.value)}
disabled={props.projectPending}
rows={3}
/>
<div class="flex gap-3 justify-end pt-4">
<Button type="button" variant="ghost" onClick={() => setShowCreateModal(false)}>
Cancel
</Button>
<Button type="submit" variant="primary" disabled={props.projectPending}>
{props.projectPending ? "Creating..." : "Create Project"}
</Button>
</div>
</form>
<Show when={props.projectMessage}>
<Message variant="neutral" class="mt-4">{props.projectMessage}</Message>
</Show>
</Modal>
</div>
);
}
+364
View File
@@ -0,0 +1,364 @@
import { Show, For, createSignal } from "solid-js";
import { Button, Card, Input, Badge, Table, EmptyState, Message, Modal, Tabs, TabPanel } from "../components";
import type { ApiKey } from "@primora/api-client";
interface SettingsPageProps {
apiKeys?: ApiKey[];
apiKeyName: string;
apiKeySecret: string;
apiKeyMessage: string;
apiKeyPending: boolean;
organizationInput: { name: string; slug: string };
organizationEditInput: { name: string; slug: string };
organizationMessage: string;
canUpdateOrganization: boolean;
workspacePending: boolean;
onApiKeyNameChange: (name: string) => void;
onCreateApiKey: (e: SubmitEvent) => void;
onDeleteApiKey: (id: string) => void;
onOrganizationInputChange: (field: string, value: string) => void;
onOrganizationEditInputChange: (field: string, value: string) => void;
onCreateOrganization: (e: SubmitEvent) => void;
onUpdateOrganization: (e: SubmitEvent) => void;
onDeleteOrganization: () => void;
formatDate: (date?: string | null) => string;
}
export function SettingsPage(props: SettingsPageProps) {
const [activeTab, setActiveTab] = createSignal<'api-keys' | 'organization' | 'general'>('api-keys');
const [showCreateKeyModal, setShowCreateKeyModal] = createSignal(false);
const [showKeySecret, setShowKeySecret] = createSignal(false);
const [copiedKey, setCopiedKey] = createSignal(false);
const handleCreateKey = (e: SubmitEvent) => {
props.onCreateApiKey(e);
setShowCreateKeyModal(false);
setShowKeySecret(true);
};
const copyToClipboard = (text: string) => {
navigator.clipboard.writeText(text);
setCopiedKey(true);
setTimeout(() => setCopiedKey(false), 2000);
};
return (
<div class="space-y-6">
{/* Header */}
<div>
<h1 class="text-3xl font-bold text-gray-900">Settings</h1>
<p class="text-gray-600 mt-1">Manage API keys, organization, and project settings</p>
</div>
{/* Tabs */}
<Tabs
tabs={[
{ id: 'api-keys', label: 'API Keys' },
{ id: 'organization', label: 'Organization' },
{ id: 'general', label: 'General' },
]}
activeTab={activeTab()}
onChange={(id) => setActiveTab(id as any)}
/>
{/* API Keys Tab */}
<Show when={activeTab() === 'api-keys'}>
<div class="space-y-6">
<Card class="p-6">
<div class="flex items-center justify-between mb-6">
<div>
<h2 class="text-xl font-semibold">API Keys</h2>
<p class="text-sm text-gray-600 mt-1">Manage authentication keys for your project</p>
</div>
<Button onClick={() => setShowCreateKeyModal(true)}>
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4" />
</svg>
Create API Key
</Button>
</div>
<Show
when={(props.apiKeys?.length ?? 0) > 0}
fallback={
<EmptyState
icon={
<svg class="w-16 h-16" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1121 9z" />
</svg>
}
title="No API keys"
description="Create an API key to authenticate your applications"
action={
<Button onClick={() => setShowCreateKeyModal(true)}>Create API Key</Button>
}
/>
}
>
<Table>
<thead>
<tr>
<th>Name</th>
<th>Key ID</th>
<th>Status</th>
<th>Created</th>
<th>Last Used</th>
<th class="text-right">Actions</th>
</tr>
</thead>
<tbody>
<For each={props.apiKeys}>
{(key) => (
<tr>
<td class="font-medium text-gray-900">{key.name}</td>
<td>
<code class="text-xs bg-gray-100 px-2 py-1 rounded">
{key.key_id}
</code>
</td>
<td>
<Badge variant={key.is_active ? 'success' : 'secondary'}>
{key.is_active ? 'Active' : 'Inactive'}
</Badge>
</td>
<td class="text-sm text-gray-600">{props.formatDate(key.created_at)}</td>
<td class="text-sm text-gray-600">
{key.last_used_at ? props.formatDate(key.last_used_at) : 'Never'}
</td>
<td class="text-right">
<Button
variant="ghost"
size="sm"
onClick={() => props.onDeleteApiKey(key.id)}
disabled={props.apiKeyPending}
>
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
</svg>
</Button>
</td>
</tr>
)}
</For>
</tbody>
</Table>
</Show>
</Card>
<Card class="p-6 bg-blue-50 border-blue-200">
<div class="flex gap-4">
<div class="flex-shrink-0">
<svg class="w-6 h-6 text-blue-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
</div>
<div>
<h3 class="font-semibold text-blue-900 mb-1">Keep your API keys secure</h3>
<p class="text-sm text-blue-800">
API keys provide full access to your project. Never share them publicly or commit them to version control.
Store them securely using environment variables or secret management services.
</p>
</div>
</div>
</Card>
<Show when={props.apiKeyMessage}>
<Message variant="neutral">{props.apiKeyMessage}</Message>
</Show>
</div>
</Show>
{/* Organization Tab */}
<Show when={activeTab() === 'organization'}>
<div class="space-y-6">
<Card class="p-6">
<h2 class="text-xl font-semibold mb-4">Organization Settings</h2>
<form class="space-y-4" onSubmit={props.onUpdateOrganization}>
<Input
label="Organization Name"
placeholder="My Organization"
value={props.organizationEditInput.name}
onInput={(e) => props.onOrganizationEditInputChange('name', e.currentTarget.value)}
disabled={props.workspacePending || !props.canUpdateOrganization}
/>
<Input
label="Organization Slug"
placeholder="my-organization"
value={props.organizationEditInput.slug}
onInput={(e) => props.onOrganizationEditInputChange('slug', e.currentTarget.value)}
disabled={props.workspacePending || !props.canUpdateOrganization}
/>
<Show when={props.canUpdateOrganization}>
<div class="flex gap-3">
<Button type="submit" variant="primary" disabled={props.workspacePending}>
{props.workspacePending ? "Updating..." : "Update Organization"}
</Button>
<Button type="button" variant="danger" onClick={props.onDeleteOrganization} disabled={props.workspacePending}>
Delete Organization
</Button>
</div>
</Show>
</form>
<Show when={props.organizationMessage}>
<Message variant="neutral" class="mt-4">{props.organizationMessage}</Message>
</Show>
</Card>
<Card class="p-6 bg-red-50 border-red-200">
<div class="flex gap-4">
<div class="flex-shrink-0">
<svg class="w-6 h-6 text-red-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
</svg>
</div>
<div>
<h3 class="font-semibold text-red-900 mb-1">Danger Zone</h3>
<p class="text-sm text-red-800">
Deleting an organization is permanent and cannot be undone. All projects, members, API keys, buckets, and data will be permanently deleted.
</p>
</div>
</div>
</Card>
</div>
</Show>
{/* General Tab */}
<Show when={activeTab() === 'general'}>
<div class="space-y-6">
<Card class="p-6">
<h2 class="text-xl font-semibold mb-4">General Settings</h2>
<div class="space-y-4">
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">Theme</label>
<select class="input w-full max-w-xs">
<option>Light</option>
<option>Dark</option>
<option>System</option>
</select>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">Language</label>
<select class="input w-full max-w-xs">
<option>English</option>
<option>Spanish</option>
<option>French</option>
<option>German</option>
</select>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">Timezone</label>
<select class="input w-full max-w-xs">
<option>UTC</option>
<option>America/New_York</option>
<option>America/Los_Angeles</option>
<option>Europe/London</option>
<option>Asia/Tokyo</option>
</select>
</div>
</div>
</Card>
<Card class="p-6">
<h2 class="text-xl font-semibold mb-4">Notifications</h2>
<div class="space-y-3">
<label class="flex items-center gap-3">
<input type="checkbox" class="rounded border-gray-300" checked />
<div>
<p class="font-medium text-gray-900">Email notifications</p>
<p class="text-sm text-gray-600">Receive email updates about your projects</p>
</div>
</label>
<label class="flex items-center gap-3">
<input type="checkbox" class="rounded border-gray-300" checked />
<div>
<p class="font-medium text-gray-900">Security alerts</p>
<p class="text-sm text-gray-600">Get notified about security events</p>
</div>
</label>
<label class="flex items-center gap-3">
<input type="checkbox" class="rounded border-gray-300" />
<div>
<p class="font-medium text-gray-900">Product updates</p>
<p class="text-sm text-gray-600">Stay informed about new features</p>
</div>
</label>
</div>
</Card>
</div>
</Show>
{/* Create API Key Modal */}
<Modal open={showCreateKeyModal()} onClose={() => setShowCreateKeyModal(false)} title="Create API Key">
<form class="space-y-4" onSubmit={handleCreateKey}>
<Input
label="Key Name"
placeholder="Production API Key"
value={props.apiKeyName}
onInput={(e) => props.onApiKeyNameChange(e.currentTarget.value)}
disabled={props.apiKeyPending}
required
/>
<div class="bg-yellow-50 border border-yellow-200 rounded-lg p-4">
<p class="text-sm text-yellow-800">
<strong>Important:</strong> The API key secret will only be shown once. Make sure to copy and store it securely.
</p>
</div>
<div class="flex gap-3 justify-end pt-4">
<Button type="button" variant="ghost" onClick={() => setShowCreateKeyModal(false)}>
Cancel
</Button>
<Button type="submit" variant="primary" disabled={props.apiKeyPending}>
{props.apiKeyPending ? "Creating..." : "Create Key"}
</Button>
</div>
</form>
</Modal>
{/* API Key Secret Modal */}
<Modal open={showKeySecret()} onClose={() => setShowKeySecret(false)} title="API Key Created">
<div class="space-y-4">
<div class="bg-green-50 border border-green-200 rounded-lg p-4">
<p class="text-sm text-green-800 mb-2">
<strong>Success!</strong> Your API key has been created.
</p>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">API Key Secret</label>
<div class="flex gap-2">
<code class="flex-1 p-3 bg-gray-900 text-gray-100 rounded-lg text-sm font-mono break-all">
{props.apiKeySecret}
</code>
<Button
variant="outline"
onClick={() => copyToClipboard(props.apiKeySecret)}
>
{copiedKey() ? (
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7" />
</svg>
) : (
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z" />
</svg>
)}
</Button>
</div>
</div>
<div class="bg-red-50 border border-red-200 rounded-lg p-4">
<p class="text-sm text-red-800">
<strong>Warning:</strong> This is the only time you'll see this secret. Copy it now and store it securely.
</p>
</div>
<div class="flex justify-end pt-4">
<Button onClick={() => setShowKeySecret(false)}>
I've Saved the Key
</Button>
</div>
</div>
</Modal>
</div>
);
}
+441
View File
@@ -0,0 +1,441 @@
import { Show, For, createSignal } from "solid-js";
import { Button, Card, Input, Select, Badge, Table, EmptyState, Message, Modal, FileInput } from "../components";
import type { Bucket, BucketObject } from "@primora/api-client";
interface StoragePageProps {
buckets?: Bucket[];
objects?: BucketObject[];
selectedBucketID?: string;
selectedObjectKey?: string;
bucketInput: { name: string; slug: string; visibility: string };
bucketEditInput: { name: string; slug: string; visibility: string };
storageMessage: string;
storagePending: boolean;
canUpdateBucket: boolean;
objectsPage?: { items: BucketObject[]; total: number; limit: number; offset: number };
objectPreview?: any;
onBucketInputChange: (field: string, value: string) => void;
onBucketEditInputChange: (field: string, value: string) => void;
onCreateBucket: (e: SubmitEvent) => void;
onUpdateBucket: (e: SubmitEvent) => void;
onDeleteBucket: () => void;
onSelectBucket: (id: string) => void;
onSelectObject: (key: string) => void;
onUploadObject: (file: File) => void;
onDeleteObject: (key: string) => void;
onDownloadObject: (bucketId: string, key: string) => void;
onObjectPageChange: (offset: number) => void;
formatBytes: (bytes: number) => string;
formatDate: (date?: string | null) => string;
}
export function StoragePage(props: StoragePageProps) {
const [showCreateBucketModal, setShowCreateBucketModal] = createSignal(false);
const [showUploadModal, setShowUploadModal] = createSignal(false);
const [showObjectPreview, setShowObjectPreview] = createSignal(false);
const [searchQuery, setSearchQuery] = createSignal("");
const [selectedFile, setSelectedFile] = createSignal<File | undefined>();
const filteredBuckets = () => {
const query = searchQuery().toLowerCase();
if (!query || !props.buckets) return props.buckets || [];
return props.buckets.filter(b =>
b.name.toLowerCase().includes(query) ||
b.slug.toLowerCase().includes(query)
);
};
const handleCreateBucket = (e: SubmitEvent) => {
props.onCreateBucket(e);
setShowCreateBucketModal(false);
};
const handleUpload = () => {
const file = selectedFile();
if (file) {
props.onUploadObject(file);
setSelectedFile(undefined);
setShowUploadModal(false);
}
};
const getVisibilityBadge = (visibility: string) => {
return visibility === 'public' ?
<Badge variant="success">Public</Badge> :
<Badge variant="secondary">Private</Badge>;
};
return (
<div class="space-y-6">
{/* Header */}
<div class="flex items-center justify-between">
<div>
<h1 class="text-3xl font-bold text-gray-900">Storage</h1>
<p class="text-gray-600 mt-1">Manage buckets and objects</p>
</div>
<div class="flex gap-3">
<Show when={props.selectedBucketID}>
<Button variant="secondary" onClick={() => setShowUploadModal(true)}>
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12" />
</svg>
Upload File
</Button>
</Show>
<Button onClick={() => setShowCreateBucketModal(true)}>
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4" />
</svg>
New Bucket
</Button>
</div>
</div>
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6">
{/* Buckets List */}
<div class="lg:col-span-1 space-y-4">
<Card class="p-4">
<Input
placeholder="Search buckets..."
value={searchQuery()}
onInput={(e) => setSearchQuery(e.currentTarget.value)}
class="w-full"
/>
</Card>
<Card class="p-4">
<h2 class="text-lg font-semibold mb-4">Buckets</h2>
<Show
when={filteredBuckets().length > 0}
fallback={
<EmptyState
icon={
<svg class="w-12 h-12" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M5 8h14M5 8a2 2 0 110-4h14a2 2 0 110 4M5 8v10a2 2 0 002 2h10a2 2 0 002-2V8m-9 3h4m-4 4h4" />
</svg>
}
title="No buckets"
description="Create a bucket to store files"
/>
}
>
<div class="space-y-2">
<For each={filteredBuckets()}>
{(bucket) => (
<button
onClick={() => props.onSelectBucket(bucket.id)}
class={`w-full text-left p-3 rounded-lg border transition-all ${
props.selectedBucketID === bucket.id
? 'border-blue-500 bg-blue-50'
: 'border-gray-200 hover:border-gray-300 hover:bg-gray-50'
}`}
>
<div class="flex items-center justify-between mb-1">
<span class="font-medium text-gray-900">{bucket.name}</span>
{getVisibilityBadge(bucket.visibility)}
</div>
<p class="text-xs text-gray-600">{bucket.slug}</p>
<p class="text-xs text-gray-500 mt-1">
{bucket.object_count} objects • {props.formatBytes(bucket.size_bytes)}
</p>
</button>
)}
</For>
</div>
</Show>
</Card>
{/* Bucket Settings */}
<Show when={props.selectedBucketID && props.canUpdateBucket}>
<Card class="p-4">
<h2 class="text-lg font-semibold mb-4">Bucket Settings</h2>
<form class="space-y-3" onSubmit={props.onUpdateBucket}>
<Input
label="Name"
value={props.bucketEditInput.name}
onInput={(e) => props.onBucketEditInputChange('name', e.currentTarget.value)}
disabled={props.storagePending}
/>
<Input
label="Slug"
value={props.bucketEditInput.slug}
onInput={(e) => props.onBucketEditInputChange('slug', e.currentTarget.value)}
disabled={props.storagePending}
/>
<Select
label="Visibility"
value={props.bucketEditInput.visibility}
onChange={(e) => props.onBucketEditInputChange('visibility', e.currentTarget.value)}
disabled={props.storagePending}
>
<option value="private">Private</option>
<option value="public">Public</option>
</Select>
<div class="flex flex-col gap-2">
<Button type="submit" variant="primary" size="sm" disabled={props.storagePending}>
Update
</Button>
<Button type="button" variant="danger" size="sm" onClick={props.onDeleteBucket} disabled={props.storagePending}>
Delete Bucket
</Button>
</div>
</form>
</Card>
</Show>
</div>
{/* Objects List */}
<div class="lg:col-span-2">
<Show
when={props.selectedBucketID}
fallback={
<Card class="p-12">
<EmptyState
icon={
<svg class="w-16 h-16" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M5 8h14M5 8a2 2 0 110-4h14a2 2 0 110 4M5 8v10a2 2 0 002 2h10a2 2 0 002-2V8m-9 3h4m-4 4h4" />
</svg>
}
title="Select a bucket"
description="Choose a bucket from the left to view its contents"
/>
</Card>
}
>
<Card>
<div class="p-4 border-b border-gray-200">
<div class="flex items-center justify-between">
<h2 class="text-lg font-semibold">Objects</h2>
<Button size="sm" onClick={() => setShowUploadModal(true)}>
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12" />
</svg>
Upload
</Button>
</div>
</div>
<Show
when={(props.objects?.length ?? 0) > 0}
fallback={
<div class="p-12">
<EmptyState
icon={
<svg class="w-16 h-16" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z" />
</svg>
}
title="No objects"
description="Upload files to this bucket"
action={
<Button onClick={() => setShowUploadModal(true)}>Upload File</Button>
}
/>
</div>
}
>
<Table>
<thead>
<tr>
<th>Name</th>
<th>Size</th>
<th>Type</th>
<th>Modified</th>
<th class="text-right">Actions</th>
</tr>
</thead>
<tbody>
<For each={props.objects}>
{(object) => (
<tr class="hover:bg-gray-50">
<td>
<button
onClick={() => {
props.onSelectObject(object.object_key);
setShowObjectPreview(true);
}}
class="flex items-center gap-2 text-left hover:text-blue-600"
>
<svg class="w-5 h-5 text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z" />
</svg>
<span class="font-medium">{object.object_key}</span>
</button>
</td>
<td class="text-sm text-gray-600">{props.formatBytes(object.size_bytes)}</td>
<td>
<Badge variant="secondary" class="text-xs">
{object.content_type || 'unknown'}
</Badge>
</td>
<td class="text-sm text-gray-600">{props.formatDate(object.updated_at)}</td>
<td class="text-right">
<div class="flex items-center justify-end gap-2">
<Button
variant="ghost"
size="sm"
onClick={() => props.onDownloadObject(props.selectedBucketID!, object.object_key)}
>
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" />
</svg>
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => props.onDeleteObject(object.object_key)}
>
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
</svg>
</Button>
</div>
</td>
</tr>
)}
</For>
</tbody>
</Table>
{/* Pagination */}
<Show when={props.objectsPage && props.objectsPage.total > props.objectsPage.limit}>
<div class="p-4 border-t border-gray-200 flex items-center justify-between">
<p class="text-sm text-gray-600">
Showing {props.objectsPage!.offset + 1} to {Math.min(props.objectsPage!.offset + props.objectsPage!.limit, props.objectsPage!.total)} of {props.objectsPage!.total}
</p>
<div class="flex gap-2">
<Button
variant="ghost"
size="sm"
disabled={props.objectsPage!.offset === 0}
onClick={() => props.onObjectPageChange(Math.max(0, props.objectsPage!.offset - props.objectsPage!.limit))}
>
Previous
</Button>
<Button
variant="ghost"
size="sm"
disabled={props.objectsPage!.offset + props.objectsPage!.limit >= props.objectsPage!.total}
onClick={() => props.onObjectPageChange(props.objectsPage!.offset + props.objectsPage!.limit)}
>
Next
</Button>
</div>
</div>
</Show>
</Show>
</Card>
</Show>
</div>
</div>
<Show when={props.storageMessage}>
<Message variant="neutral">{props.storageMessage}</Message>
</Show>
{/* Create Bucket Modal */}
<Modal open={showCreateBucketModal()} onClose={() => setShowCreateBucketModal(false)} title="Create New Bucket">
<form class="space-y-4" onSubmit={handleCreateBucket}>
<Input
label="Bucket Name"
placeholder="my-bucket"
value={props.bucketInput.name}
onInput={(e) => {
const name = e.currentTarget.value;
props.onBucketInputChange('name', name);
const slug = name.toLowerCase().trim().replace(/[^a-z0-9\s-]/g, '').replace(/\s+/g, '-');
props.onBucketInputChange('slug', slug);
}}
disabled={props.storagePending}
required
/>
<Input
label="Bucket Slug"
placeholder="my-bucket"
value={props.bucketInput.slug}
onInput={(e) => props.onBucketInputChange('slug', e.currentTarget.value)}
disabled={props.storagePending}
required
/>
<Select
label="Visibility"
value={props.bucketInput.visibility}
onChange={(e) => props.onBucketInputChange('visibility', e.currentTarget.value)}
disabled={props.storagePending}
>
<option value="private">Private</option>
<option value="public">Public</option>
</Select>
<div class="flex gap-3 justify-end pt-4">
<Button type="button" variant="ghost" onClick={() => setShowCreateBucketModal(false)}>
Cancel
</Button>
<Button type="submit" variant="primary" disabled={props.storagePending}>
{props.storagePending ? "Creating..." : "Create Bucket"}
</Button>
</div>
</form>
</Modal>
{/* Upload Modal */}
<Modal open={showUploadModal()} onClose={() => setShowUploadModal(false)} title="Upload File">
<div class="space-y-4">
<FileInput
label="Select File"
onChange={(file) => setSelectedFile(file)}
accept="*/*"
/>
<Show when={selectedFile()}>
<div class="p-4 bg-gray-50 rounded-lg">
<p class="text-sm font-medium text-gray-900">{selectedFile()!.name}</p>
<p class="text-xs text-gray-600 mt-1">
{props.formatBytes(selectedFile()!.size)} • {selectedFile()!.type || 'unknown type'}
</p>
</div>
</Show>
<div class="flex gap-3 justify-end pt-4">
<Button type="button" variant="ghost" onClick={() => setShowUploadModal(false)}>
Cancel
</Button>
<Button
variant="primary"
disabled={!selectedFile() || props.storagePending}
onClick={handleUpload}
>
{props.storagePending ? "Uploading..." : "Upload"}
</Button>
</div>
</div>
</Modal>
{/* Object Preview Modal */}
<Modal
open={showObjectPreview()}
onClose={() => setShowObjectPreview(false)}
title="Object Preview"
size="lg"
>
<Show when={props.objectPreview}>
<div class="space-y-4">
<Show when={props.objectPreview.kind === 'image'}>
<img src={props.objectPreview.objectURL} alt="Preview" class="w-full rounded-lg" />
</Show>
<Show when={props.objectPreview.kind === 'text'}>
<pre class="p-4 bg-gray-900 text-gray-100 rounded-lg overflow-x-auto text-sm">
{props.objectPreview.text}
</pre>
<Show when={props.objectPreview.truncated}>
<p class="text-sm text-yellow-600">Preview truncated. Download to view full content.</p>
</Show>
</Show>
<Show when={props.objectPreview.kind === 'unsupported'}>
<div class="text-center p-8">
<p class="text-gray-600">{props.objectPreview.message}</p>
</div>
</Show>
</div>
</Show>
</Modal>
</div>
);
}
+6
View File
@@ -0,0 +1,6 @@
// Pages barrel export
export { ProjectsPage } from "./ProjectsPage";
export { MembersPage } from "./MembersPage";
export { StoragePage } from "./StoragePage";
export { SettingsPage } from "./SettingsPage";
export { AuditPage } from "./AuditPage";