-
Host workspace
-
Elixpo Community
+
+
Host workspace
+
Elixpo Community
- {navigation.map(([label, href], index) => {label})}
+ {navigation.map(([label, href], index) => {label})}
-
+
);
}
diff --git a/components/shell/Breadcrumbs.tsx b/components/shell/Breadcrumbs.tsx
new file mode 100644
index 0000000..0e564d2
--- /dev/null
+++ b/components/shell/Breadcrumbs.tsx
@@ -0,0 +1,40 @@
+'use client';
+
+import * as React from 'react';
+import Link from 'next/link';
+import { usePathname } from 'next/navigation';
+
+
+export function Breadcrumbs() {
+ const pathname = usePathname();
+ const segments = pathname.split('/').filter(Boolean);
+
+ if (segments.length === 0) return null;
+
+ return (
+
+
+
+ Home
+
+ {segments.map((segment: string, index: number) => {
+ const isLast = index === segments.length - 1;
+ const href = `/${segments.slice(0, index + 1).join('/')}`;
+
+ return (
+
+ /
+
+ {isLast ? (
+ {segment}
+ ) : (
+ {segment}
+ )}
+
+
+ );
+ })}
+
+
+ );
+}
diff --git a/components/shell/CommandSearch.tsx b/components/shell/CommandSearch.tsx
new file mode 100644
index 0000000..6e64269
--- /dev/null
+++ b/components/shell/CommandSearch.tsx
@@ -0,0 +1,81 @@
+'use client';
+
+import * as React from 'react';
+
+import { Icon } from '@/components/icons';
+
+const SearchIcon = () => (
+
+
+
+
+);
+
+export function CommandSearch() {
+ const [open, setOpen] = React.useState(false);
+ const inputRef = React.useRef
(null);
+
+ React.useEffect(() => {
+ const down = (e: KeyboardEvent) => {
+ if (e.key === 'k' && (e.metaKey || e.ctrlKey)) {
+ e.preventDefault();
+ setOpen((open) => !open);
+ }
+ };
+
+ document.addEventListener('keydown', down);
+ return () => document.removeEventListener('keydown', down);
+ }, []);
+
+ React.useEffect(() => {
+ if (open) inputRef.current?.focus();
+ }, [open]);
+
+ return (
+ <>
+ setOpen(true)}
+ className="flex items-center gap-2 rounded-md border border-muted/20 bg-bg px-3 py-1.5 text-sm text-muted transition-colors hover:bg-card w-full max-w-[240px] sm:max-w-xs"
+ >
+
+ Search or type a command...
+
+ ⌘ K
+
+
+
+ {open && (
+
+
setOpen(false)}
+ />
+
+
+
+
+
+
+
Suggestions (Mock)
+
+
+ Go to Dashboard
+
+
+ Create new Issue
+
+
+
+
+
+ )}
+ >
+ );
+}
diff --git a/components/shell/GlobalNav.tsx b/components/shell/GlobalNav.tsx
new file mode 100644
index 0000000..ca59814
--- /dev/null
+++ b/components/shell/GlobalNav.tsx
@@ -0,0 +1,33 @@
+import { Logo } from '@/components/logo';
+import { publicEnv } from '@/lib/env';
+import { RoleSwitcher } from './RoleSwitcher';
+import { CommandSearch } from './CommandSearch';
+
+export function GlobalNav({ userRoles = ['contributor'], currentRole = 'contributor' }: { userRoles?: string[], currentRole?: string }) {
+ return (
+
+ );
+}
diff --git a/components/shell/RoleSwitcher.tsx b/components/shell/RoleSwitcher.tsx
new file mode 100644
index 0000000..a2c1f4b
--- /dev/null
+++ b/components/shell/RoleSwitcher.tsx
@@ -0,0 +1,45 @@
+'use client';
+
+import * as React from 'react';
+
+import { cn } from '@/lib/utils';
+
+export function RoleSwitcher({ roles, currentRole }: { roles: string[], currentRole: string }) {
+ const [open, setOpen] = React.useState(false);
+
+ return (
+
+
setOpen(!open)}
+ >
+ {currentRole}
+
+
+
+ {open && (
+
+
Switch Role
+ {roles.map(role => (
+
{
+ // Mock implementation for UI states phase
+ setOpen(false);
+ alert(`Switching to ${role} role (mock)`);
+ }}
+ >
+ {role}
+
+ ))}
+
+ )}
+
+ );
+}
diff --git a/components/shell/WorkspaceShell.tsx b/components/shell/WorkspaceShell.tsx
new file mode 100644
index 0000000..7da443f
--- /dev/null
+++ b/components/shell/WorkspaceShell.tsx
@@ -0,0 +1,34 @@
+import type * as React from 'react';
+import { GlobalNav } from './GlobalNav';
+import { Breadcrumbs } from './Breadcrumbs';
+
+export function WorkspaceShell({
+ children,
+ userRoles = ['contributor'],
+ currentRole = 'contributor'
+}: {
+ children: React.ReactNode,
+ userRoles?: string[],
+ currentRole?: string
+}) {
+ return (
+
+ );
+}
diff --git a/components/states/EmptyState.tsx b/components/states/EmptyState.tsx
new file mode 100644
index 0000000..c5ec73f
--- /dev/null
+++ b/components/states/EmptyState.tsx
@@ -0,0 +1,26 @@
+import type * as React from 'react';
+
+export function EmptyState({
+ icon,
+ title,
+ description,
+ action
+}: {
+ icon?: React.ReactNode,
+ title: string,
+ description?: string,
+ action?: React.ReactNode
+}) {
+ return (
+
+ {icon && (
+
+ {icon}
+
+ )}
+
{title}
+ {description &&
{description}
}
+ {action &&
{action}
}
+
+ );
+}
diff --git a/components/states/ErrorState.tsx b/components/states/ErrorState.tsx
new file mode 100644
index 0000000..27d4992
--- /dev/null
+++ b/components/states/ErrorState.tsx
@@ -0,0 +1,30 @@
+'use client';
+
+import { Icon } from '@/components/icons';
+import { Button } from '@/components/ui/Button';
+
+// Basic Alert Circle Icon for error state
+const AlertCircle = () => (
+
+
+
+
+
+);
+
+export function ErrorState({ title = "Something went wrong", message = "There was an error loading this data. Please try again.", onRetry }: { title?: string, message?: string, onRetry?: () => void }) {
+ return (
+
+
+
{title}
+
{message}
+ {onRetry && (
+
+ Try again
+
+ )}
+
+ );
+}
diff --git a/components/states/LoadingState.tsx b/components/states/LoadingState.tsx
new file mode 100644
index 0000000..26fdc35
--- /dev/null
+++ b/components/states/LoadingState.tsx
@@ -0,0 +1,20 @@
+import type * as React from 'react';
+import { cn } from '@/lib/utils';
+
+export function LoadingState({ text = 'Loading...' }: { text?: string }) {
+ return (
+
+ );
+}
+
+export function Skeleton({ className, ...props }: React.HTMLAttributes) {
+ return (
+
+ );
+}
diff --git a/components/states/OfflineState.tsx b/components/states/OfflineState.tsx
new file mode 100644
index 0000000..6a17b27
--- /dev/null
+++ b/components/states/OfflineState.tsx
@@ -0,0 +1,39 @@
+'use client';
+
+import { Icon } from '@/components/icons';
+import { Button } from '@/components/ui/Button';
+
+// Wifi Off Icon for offline state
+const WifiOff = () => (
+
+
+
+
+
+
+);
+
+export function OfflineState({
+ title = "You're offline",
+ message = "Please check your internet connection and try again.",
+ onRetry
+}: {
+ title?: string,
+ message?: string,
+ onRetry?: () => void
+}) {
+ return (
+
+
+
+
+
{title}
+
{message}
+ {onRetry && (
+
+ Try again
+
+ )}
+
+ );
+}
diff --git a/components/states/PermissionDenied.tsx b/components/states/PermissionDenied.tsx
new file mode 100644
index 0000000..0d810ed
--- /dev/null
+++ b/components/states/PermissionDenied.tsx
@@ -0,0 +1,26 @@
+'use client';
+
+import { Icon } from '@/components/icons';
+import { Button } from '@/components/ui/Button';
+
+const LockIcon = () => (
+
+
+
+
+);
+
+export function PermissionDenied({ message = "You don't have permission to access this resource." }: { message?: string }) {
+ return (
+
+
+
+
+
Access Denied
+
{message}
+
window.history.back()}>
+ Go back
+
+
+ );
+}
diff --git a/components/ui/Button.tsx b/components/ui/Button.tsx
new file mode 100644
index 0000000..1cb7037
--- /dev/null
+++ b/components/ui/Button.tsx
@@ -0,0 +1,31 @@
+import * as React from 'react';
+import { cn } from '@/lib/utils';
+
+export interface ButtonProps extends React.ButtonHTMLAttributes {
+ variant?: 'primary' | 'secondary' | 'ghost';
+ size?: 'sm' | 'md' | 'lg';
+}
+
+export const Button = React.forwardRef(
+ ({ className, variant = 'primary', size = 'md', ...props }, ref) => {
+ return (
+
+ );
+ }
+);
+Button.displayName = 'Button';
diff --git a/components/ui/Dialog.tsx b/components/ui/Dialog.tsx
new file mode 100644
index 0000000..036c540
--- /dev/null
+++ b/components/ui/Dialog.tsx
@@ -0,0 +1,100 @@
+'use client';
+
+import * as React from 'react';
+import { cn } from '@/lib/utils';
+
+
+// Basic dialog implementation without external libraries
+export function Dialog({ open, onOpenChange, children }: { open: boolean, onOpenChange: (open: boolean) => void, children: React.ReactNode }) {
+ const dialogRef = React.useRef(null);
+
+ React.useEffect(() => {
+ const handleKeyDown = (e: KeyboardEvent) => {
+ if (e.key === 'Escape') {
+ onOpenChange(false);
+ }
+
+ // Basic focus trap
+ if (e.key === 'Tab' && dialogRef.current) {
+ const focusableElements = dialogRef.current.querySelectorAll(
+ 'a[href], button, textarea, input[type="text"], input[type="radio"], input[type="checkbox"], select'
+ );
+ const firstElement = focusableElements[0] as HTMLElement;
+ const lastElement = focusableElements[focusableElements.length - 1] as HTMLElement;
+
+ if (e.shiftKey) {
+ if (document.activeElement === firstElement) {
+ lastElement?.focus();
+ e.preventDefault();
+ }
+ } else {
+ if (document.activeElement === lastElement) {
+ firstElement?.focus();
+ e.preventDefault();
+ }
+ }
+ }
+ };
+
+ if (open) {
+ document.addEventListener('keydown', handleKeyDown);
+ document.body.style.overflow = 'hidden';
+ // Focus the dialog itself or its first focusable element when opened
+ setTimeout(() => {
+ if (dialogRef.current) {
+ const firstElement = dialogRef.current.querySelector(
+ 'a[href], button, textarea, input[type="text"], input[type="radio"], input[type="checkbox"], select'
+ ) as HTMLElement;
+ if (firstElement) {
+ firstElement.focus();
+ } else {
+ dialogRef.current.focus();
+ }
+ }
+ }, 0);
+ }
+
+ return () => {
+ document.removeEventListener('keydown', handleKeyDown);
+ document.body.style.overflow = '';
+ };
+ }, [open, onOpenChange]);
+
+ if (!open) return null;
+
+ return (
+
+
onOpenChange(false)}
+ />
+
+ {children}
+
+
+ );
+}
+
+export function DialogHeader({ className, ...props }: React.HTMLAttributes) {
+ return
;
+}
+
+export function DialogFooter({ className, ...props }: React.HTMLAttributes) {
+ return
;
+}
+
+export function DialogTitle({ className, ...props }: React.HTMLAttributes) {
+ return ;
+}
+
+export function DialogDescription({ className, ...props }: React.HTMLAttributes) {
+ return
;
+}
diff --git a/components/ui/Drawer.tsx b/components/ui/Drawer.tsx
new file mode 100644
index 0000000..ac02678
--- /dev/null
+++ b/components/ui/Drawer.tsx
@@ -0,0 +1,81 @@
+'use client';
+
+import * as React from 'react';
+
+export function Drawer({ open, onOpenChange, children }: { open: boolean, onOpenChange: (open: boolean) => void, children: React.ReactNode }) {
+ const drawerRef = React.useRef(null);
+
+ React.useEffect(() => {
+ const handleKeyDown = (e: KeyboardEvent) => {
+ if (e.key === 'Escape') {
+ onOpenChange(false);
+ }
+
+ // Basic focus trap
+ if (e.key === 'Tab' && drawerRef.current) {
+ const focusableElements = drawerRef.current.querySelectorAll(
+ 'a[href], button, textarea, input[type="text"], input[type="radio"], input[type="checkbox"], select'
+ );
+ const firstElement = focusableElements[0] as HTMLElement;
+ const lastElement = focusableElements[focusableElements.length - 1] as HTMLElement;
+
+ if (e.shiftKey) {
+ if (document.activeElement === firstElement) {
+ lastElement?.focus();
+ e.preventDefault();
+ }
+ } else {
+ if (document.activeElement === lastElement) {
+ firstElement?.focus();
+ e.preventDefault();
+ }
+ }
+ }
+ };
+
+ if (open) {
+ document.addEventListener('keydown', handleKeyDown);
+ document.body.style.overflow = 'hidden';
+ // Focus the drawer itself or its first focusable element when opened
+ setTimeout(() => {
+ if (drawerRef.current) {
+ const firstElement = drawerRef.current.querySelector(
+ 'a[href], button, textarea, input[type="text"], input[type="radio"], input[type="checkbox"], select'
+ ) as HTMLElement;
+ if (firstElement) {
+ firstElement.focus();
+ } else {
+ drawerRef.current.focus();
+ }
+ }
+ }, 0);
+ }
+
+ return () => {
+ document.removeEventListener('keydown', handleKeyDown);
+ document.body.style.overflow = '';
+ };
+ }, [open, onOpenChange]);
+
+ if (!open) return null;
+
+ return (
+
+
onOpenChange(false)}
+ />
+
+ {children}
+
+
+ );
+}
diff --git a/components/ui/Filters.tsx b/components/ui/Filters.tsx
new file mode 100644
index 0000000..82f1346
--- /dev/null
+++ b/components/ui/Filters.tsx
@@ -0,0 +1,112 @@
+'use client';
+
+import type * as React from 'react';
+import { cn } from '@/lib/utils';
+import { Input } from './Input';
+import { Select } from './Select';
+import { Button } from './Button';
+import { Icon } from '@/components/icons';
+
+const SearchIcon = () => (
+
+
+
+
+);
+
+const FilterIcon = () => (
+
+
+
+);
+
+export interface FilterOption {
+ value: string;
+ label: string;
+}
+
+export interface FilterDefinition {
+ id: string;
+ label: string;
+ options: FilterOption[];
+}
+
+export interface FiltersProps extends React.HTMLAttributes {
+ searchPlaceholder?: string;
+ searchValue?: string;
+ onSearchChange?: (value: string) => void;
+ filters?: FilterDefinition[];
+ filterValues?: Record;
+ onFilterChange?: (id: string, value: string) => void;
+ onClearFilters?: () => void;
+}
+
+export function Filters({
+ className,
+ searchPlaceholder = 'Search...',
+ searchValue,
+ onSearchChange,
+ filters = [],
+ filterValues = {},
+ onFilterChange,
+ onClearFilters,
+ ...props
+}: FiltersProps) {
+ const hasActiveFilters = Object.values(filterValues).some(val => val !== '');
+
+ return (
+
+
+
+
+
+
onSearchChange?.(e.target.value)}
+ className="pl-9"
+ />
+
+
+ {(filters.length > 0 || hasActiveFilters) && (
+
+ {filters.length > 0 && (
+
+
+ Filters:
+
+ )}
+
+ {filters.map((filter) => (
+
+ onFilterChange?.(filter.id, e.target.value)}
+ aria-label={filter.label}
+ >
+ {filter.label}
+ {filter.options.map((option) => (
+
+ {option.label}
+
+ ))}
+
+
+ ))}
+
+ {hasActiveFilters && onClearFilters && (
+
+ Clear filters
+
+ )}
+
+ )}
+
+ );
+}
diff --git a/components/ui/Input.tsx b/components/ui/Input.tsx
new file mode 100644
index 0000000..77edab9
--- /dev/null
+++ b/components/ui/Input.tsx
@@ -0,0 +1,21 @@
+import * as React from 'react';
+import { cn } from '@/lib/utils';
+
+export type InputProps = React.InputHTMLAttributes;
+
+export const Input = React.forwardRef(
+ ({ className, type, ...props }, ref) => {
+ return (
+
+ );
+ }
+);
+Input.displayName = 'Input';
diff --git a/components/ui/Pagination.tsx b/components/ui/Pagination.tsx
new file mode 100644
index 0000000..3ec5be5
--- /dev/null
+++ b/components/ui/Pagination.tsx
@@ -0,0 +1,149 @@
+'use client';
+
+import * as React from 'react';
+import { cn } from '@/lib/utils';
+import { Icon } from '@/components/icons';
+
+const ChevronLeft = () => (
+
+
+
+);
+
+const ChevronRight = () => (
+
+
+
+);
+
+const MoreHorizontal = () => (
+
+
+
+
+
+);
+
+export interface PaginationProps extends React.HTMLAttributes {
+ currentPage: number;
+ totalPages: number;
+ onPageChange: (page: number) => void;
+ siblingCount?: number;
+}
+
+export function Pagination({
+ className,
+ currentPage,
+ totalPages,
+ onPageChange,
+ siblingCount = 1,
+ ...props
+}: PaginationProps) {
+ const pages = React.useMemo(() => {
+ // Generate page numbers with ellipses
+ const range = (start: number, end: number) => {
+ const length = end - start + 1;
+ return Array.from({ length }, (_, idx) => idx + start);
+ };
+
+ const totalPageNumbers = siblingCount + 5;
+
+ if (totalPageNumbers >= totalPages) {
+ return range(1, totalPages);
+ }
+
+ const leftSiblingIndex = Math.max(currentPage - siblingCount, 1);
+ const rightSiblingIndex = Math.min(currentPage + siblingCount, totalPages);
+
+ const shouldShowLeftDots = leftSiblingIndex > 2;
+ const shouldShowRightDots = rightSiblingIndex < totalPages - 2;
+
+ const firstPageIndex = 1;
+ const lastPageIndex = totalPages;
+
+ if (!shouldShowLeftDots && shouldShowRightDots) {
+ const leftItemCount = 3 + 2 * siblingCount;
+ const leftRange = range(1, leftItemCount);
+ return [...leftRange, 'right-ellipsis', totalPages];
+ }
+
+ if (shouldShowLeftDots && !shouldShowRightDots) {
+ const rightItemCount = 3 + 2 * siblingCount;
+ const rightRange = range(totalPages - rightItemCount + 1, totalPages);
+ return [firstPageIndex, 'left-ellipsis', ...rightRange];
+ }
+
+ if (shouldShowLeftDots && shouldShowRightDots) {
+ const middleRange = range(leftSiblingIndex, rightSiblingIndex);
+ return [firstPageIndex, 'left-ellipsis', ...middleRange, 'right-ellipsis', lastPageIndex];
+ }
+
+ return [];
+ }, [totalPages, currentPage, siblingCount]);
+
+ if (currentPage === 0 || pages.length < 2) {
+ return null;
+ }
+
+ return (
+
+
+
+ onPageChange(currentPage - 1)}
+ disabled={currentPage === 1}
+ className="inline-flex h-9 items-center justify-center gap-1 rounded-md px-2.5 text-sm font-medium transition-colors hover:bg-muted/10 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary disabled:pointer-events-none disabled:opacity-50"
+ aria-label="Go to previous page"
+ >
+
+ Previous
+
+
+ {pages.map((pageNumber) => {
+ if (typeof pageNumber === 'string') {
+ return (
+
+
+ More pages
+
+ );
+ }
+
+ const isCurrent = pageNumber === currentPage;
+ return (
+
+ onPageChange(pageNumber as number)}
+ aria-current={isCurrent ? "page" : undefined}
+ className={cn(
+ "inline-flex h-9 w-9 items-center justify-center rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary disabled:pointer-events-none disabled:opacity-50",
+ isCurrent ? "border border-primary text-primary" : "hover:bg-muted/10"
+ )}
+ >
+ {pageNumber}
+
+
+ );
+ })}
+
+ onPageChange(currentPage + 1)}
+ disabled={currentPage === totalPages}
+ className="inline-flex h-9 items-center justify-center gap-1 rounded-md px-2.5 text-sm font-medium transition-colors hover:bg-muted/10 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary disabled:pointer-events-none disabled:opacity-50"
+ aria-label="Go to next page"
+ >
+ Next
+
+
+
+
+
+ );
+}
diff --git a/components/ui/Select.tsx b/components/ui/Select.tsx
new file mode 100644
index 0000000..8514c6f
--- /dev/null
+++ b/components/ui/Select.tsx
@@ -0,0 +1,22 @@
+import * as React from 'react';
+import { cn } from '@/lib/utils';
+
+export type SelectProps = React.SelectHTMLAttributes;
+
+export const Select = React.forwardRef(
+ ({ className, children, ...props }, ref) => {
+ return (
+
+ {children}
+
+ );
+ }
+);
+Select.displayName = 'Select';
diff --git a/components/ui/Table.tsx b/components/ui/Table.tsx
new file mode 100644
index 0000000..e7340a3
--- /dev/null
+++ b/components/ui/Table.tsx
@@ -0,0 +1,46 @@
+import * as React from 'react';
+import { cn } from '@/lib/utils';
+
+export const Table = React.forwardRef>(
+ ({ className, ...props }, ref) => (
+
+ )
+);
+Table.displayName = 'Table';
+
+export const TableHeader = React.forwardRef>(
+ ({ className, ...props }, ref) => (
+
+ )
+);
+TableHeader.displayName = 'TableHeader';
+
+export const TableBody = React.forwardRef>(
+ ({ className, ...props }, ref) => (
+
+ )
+);
+TableBody.displayName = 'TableBody';
+
+export const TableRow = React.forwardRef>(
+ ({ className, ...props }, ref) => (
+
+ )
+);
+TableRow.displayName = 'TableRow';
+
+export const TableHead = React.forwardRef>(
+ ({ className, ...props }, ref) => (
+
+ )
+);
+TableHead.displayName = 'TableHead';
+
+export const TableCell = React.forwardRef>(
+ ({ className, ...props }, ref) => (
+
+ )
+);
+TableCell.displayName = 'TableCell';
diff --git a/components/ui/Toast.tsx b/components/ui/Toast.tsx
new file mode 100644
index 0000000..f7abe89
--- /dev/null
+++ b/components/ui/Toast.tsx
@@ -0,0 +1,141 @@
+'use client';
+
+import * as React from 'react';
+import { cn } from '@/lib/utils';
+import { Icon } from '@/components/icons';
+
+// Basic Check Icon for success toasts
+const CheckCircle = () => (
+
+
+
+
+);
+
+// Basic Alert Circle Icon for error toasts
+const AlertCircle = () => (
+
+
+
+
+
+);
+
+// Info Icon for info toasts
+const InfoCircle = () => (
+
+
+
+
+
+);
+
+export type ToastType = 'success' | 'error' | 'info';
+
+export interface ToastData {
+ id: string;
+ title: string;
+ description?: string;
+ type?: ToastType;
+}
+
+interface ToastContextType {
+ toasts: ToastData[];
+ toast: (data: Omit) => void;
+ dismiss: (id: string) => void;
+}
+
+const ToastContext = React.createContext(undefined);
+
+export function ToastProvider({ children }: { children: React.ReactNode }) {
+ const [toasts, setToasts] = React.useState([]);
+
+ const toast = React.useCallback((data: Omit) => {
+ const id = Math.random().toString(36).slice(2, 9);
+ setToasts((prev) => [...prev, { ...data, id }]);
+
+ // Auto-dismiss after 5s
+ setTimeout(() => {
+ setToasts((prev) => prev.filter((t) => t.id !== id));
+ }, 5000);
+ }, []);
+
+ const dismiss = React.useCallback((id: string) => {
+ setToasts((prev) => prev.filter((t) => t.id !== id));
+ }, []);
+
+ return (
+
+ {children}
+
+
+ );
+}
+
+export function useToast() {
+ const context = React.useContext(ToastContext);
+ if (context === undefined) {
+ throw new Error('useToast must be used within a ToastProvider');
+ }
+ return context;
+}
+
+function ToastContainer({ toasts, dismiss }: { toasts: ToastData[], dismiss: (id: string) => void }) {
+ return (
+
+ {toasts.map((toast) => (
+ dismiss(toast.id)} />
+ ))}
+
+ );
+}
+
+function Toast({ toast, onDismiss }: { toast: ToastData, onDismiss: () => void }) {
+ const isError = toast.type === 'error';
+ const isSuccess = toast.type === 'success';
+
+ return (
+
+
+
+ {isSuccess &&
}
+ {isError &&
}
+ {!isError && !isSuccess &&
}
+
+
+ {toast.title &&
{toast.title}
}
+ {toast.description && (
+
+ {toast.description}
+
+ )}
+
+
+
+
+
+
+
+ Close
+
+
+ );
+}
diff --git a/lib/mocks/factories.ts b/lib/mocks/factories.ts
new file mode 100644
index 0000000..64919cf
--- /dev/null
+++ b/lib/mocks/factories.ts
@@ -0,0 +1,50 @@
+import type { User, HostOrganization, Contest, ContestMembership } from './schema';
+
+let idCounter = 1;
+const generateId = (prefix: string) => `${prefix}_${Date.now()}_${idCounter++}`;
+
+export const createMockUser = (overrides?: Partial): User => ({
+ id: generateId('usr'),
+ elixpo_user_id: `elx_${Date.now()}`,
+ github_login: 'mockuser',
+ display_name: 'Mock User',
+ created_at: new Date().toISOString(),
+ updated_at: new Date().toISOString(),
+ ...overrides,
+});
+
+export const createMockOrganization = (overrides?: Partial): HostOrganization => ({
+ id: generateId('org'),
+ name: 'Elixpo Foundation',
+ slug: 'elixpo-foundation',
+ owner_user_id: 'usr_1',
+ created_at: new Date().toISOString(),
+ updated_at: new Date().toISOString(),
+ ...overrides,
+});
+
+export const createMockContest = (overrides?: Partial): Contest => ({
+ id: generateId('con'),
+ host_organization_id: 'org_1',
+ name: 'Winter Open Source Fest',
+ slug: 'winter-os-fest',
+ summary: 'A month-long open source contribution event.',
+ status: 'active',
+ repository_mode: 'selected',
+ starts_at: new Date().toISOString(),
+ ends_at: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000).toISOString(),
+ created_by: 'usr_1',
+ created_at: new Date().toISOString(),
+ updated_at: new Date().toISOString(),
+ ...overrides,
+});
+
+export const createMockMembership = (overrides?: Partial): ContestMembership => ({
+ id: generateId('mem'),
+ contest_id: 'con_1',
+ user_id: 'usr_1',
+ role: 'contributor',
+ created_at: new Date().toISOString(),
+ updated_at: new Date().toISOString(),
+ ...overrides,
+});
diff --git a/lib/mocks/schema.ts b/lib/mocks/schema.ts
new file mode 100644
index 0000000..71136ab
--- /dev/null
+++ b/lib/mocks/schema.ts
@@ -0,0 +1,43 @@
+export interface User {
+ id: string;
+ elixpo_user_id: string;
+ github_login?: string;
+ display_name: string;
+ avatar_url?: string;
+ email?: string;
+ created_at: string;
+ updated_at: string;
+}
+
+export interface HostOrganization {
+ id: string;
+ name: string;
+ slug: string;
+ owner_user_id: string;
+ created_at: string;
+ updated_at: string;
+}
+
+export interface Contest {
+ id: string;
+ host_organization_id: string;
+ name: string;
+ slug: string;
+ summary: string;
+ status: 'draft' | 'applications_open' | 'active' | 'review' | 'completed' | 'archived';
+ repository_mode: 'selected' | 'organization';
+ starts_at: string;
+ ends_at: string;
+ created_by: string;
+ created_at: string;
+ updated_at: string;
+}
+
+export interface ContestMembership {
+ id: string;
+ contest_id: string;
+ user_id: string;
+ role: 'host' | 'co_host' | 'project_admin' | 'mentor' | 'campus_ambassador' | 'contributor';
+ created_at: string;
+ updated_at: string;
+}
diff --git a/lib/utils.ts b/lib/utils.ts
new file mode 100644
index 0000000..365058c
--- /dev/null
+++ b/lib/utils.ts
@@ -0,0 +1,6 @@
+import { type ClassValue, clsx } from "clsx";
+import { twMerge } from "tailwind-merge";
+
+export function cn(...inputs: ClassValue[]) {
+ return twMerge(clsx(inputs));
+}
diff --git a/package-lock.json b/package-lock.json
index 448fd45..3c3c23c 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -10,9 +10,11 @@
"license": "SEE LICENSE IN LICENSE",
"dependencies": {
"@opennextjs/cloudflare": "latest",
+ "clsx": "^2.1.1",
"next": "^15.2.0",
"react": "^19.0.0",
- "react-dom": "^19.0.0"
+ "react-dom": "^19.0.0",
+ "tailwind-merge": "^3.6.0"
},
"devDependencies": {
"@cloudflare/workers-types": "latest",
@@ -101,9 +103,6 @@
"cpu": [
"arm64"
],
- "libc": [
- "glibc"
- ],
"license": "MIT",
"optional": true,
"os": [
@@ -120,9 +119,6 @@
"cpu": [
"arm64"
],
- "libc": [
- "musl"
- ],
"license": "MIT",
"optional": true,
"os": [
@@ -1896,9 +1892,6 @@
"cpu": [
"arm"
],
- "libc": [
- "glibc"
- ],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
@@ -1915,9 +1908,6 @@
"cpu": [
"arm64"
],
- "libc": [
- "glibc"
- ],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
@@ -1934,9 +1924,6 @@
"cpu": [
"ppc64"
],
- "libc": [
- "glibc"
- ],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
@@ -1953,9 +1940,6 @@
"cpu": [
"riscv64"
],
- "libc": [
- "glibc"
- ],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
@@ -1972,9 +1956,6 @@
"cpu": [
"s390x"
],
- "libc": [
- "glibc"
- ],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
@@ -2007,9 +1988,6 @@
"cpu": [
"arm64"
],
- "libc": [
- "musl"
- ],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
@@ -2042,9 +2020,6 @@
"cpu": [
"arm"
],
- "libc": [
- "glibc"
- ],
"license": "Apache-2.0",
"optional": true,
"os": [
@@ -2067,9 +2042,6 @@
"cpu": [
"arm64"
],
- "libc": [
- "glibc"
- ],
"license": "Apache-2.0",
"optional": true,
"os": [
@@ -2092,9 +2064,6 @@
"cpu": [
"ppc64"
],
- "libc": [
- "glibc"
- ],
"license": "Apache-2.0",
"optional": true,
"os": [
@@ -2117,9 +2086,6 @@
"cpu": [
"riscv64"
],
- "libc": [
- "glibc"
- ],
"license": "Apache-2.0",
"optional": true,
"os": [
@@ -2142,9 +2108,6 @@
"cpu": [
"s390x"
],
- "libc": [
- "glibc"
- ],
"license": "Apache-2.0",
"optional": true,
"os": [
@@ -2189,9 +2152,6 @@
"cpu": [
"arm64"
],
- "libc": [
- "musl"
- ],
"license": "Apache-2.0",
"optional": true,
"os": [
@@ -2420,9 +2380,6 @@
"cpu": [
"arm64"
],
- "libc": [
- "glibc"
- ],
"license": "MIT",
"optional": true,
"os": [
@@ -2439,9 +2396,6 @@
"cpu": [
"arm64"
],
- "libc": [
- "musl"
- ],
"license": "MIT",
"optional": true,
"os": [
@@ -3902,6 +3856,15 @@
"integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==",
"license": "MIT"
},
+ "node_modules/clsx": {
+ "version": "2.1.1",
+ "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz",
+ "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ }
+ },
"node_modules/combined-stream": {
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
@@ -6292,6 +6255,16 @@
"url": "https://github.com/sponsors/ljharb"
}
},
+ "node_modules/tailwind-merge": {
+ "version": "3.6.0",
+ "resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-3.6.0.tgz",
+ "integrity": "sha512-uxL7qAVQriqRQPAyK3pj66VqskWqoZ37PW94jwOTwNfq/z9oyu1V+eqrZqtR2+fCiXdYOZe/Modt8GtvqNzu+w==",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/dcastil"
+ }
+ },
"node_modules/tailwindcss": {
"version": "3.4.19",
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.19.tgz",
diff --git a/package.json b/package.json
index 32b160c..53b2deb 100644
--- a/package.json
+++ b/package.json
@@ -37,9 +37,11 @@
},
"dependencies": {
"@opennextjs/cloudflare": "latest",
+ "clsx": "^2.1.1",
"next": "^15.2.0",
"react": "^19.0.0",
- "react-dom": "^19.0.0"
+ "react-dom": "^19.0.0",
+ "tailwind-merge": "^3.6.0"
},
"devDependencies": {
"@cloudflare/workers-types": "latest",
diff --git a/tailwind.config.ts b/tailwind.config.ts
index e577599..4154934 100644
--- a/tailwind.config.ts
+++ b/tailwind.config.ts
@@ -14,6 +14,17 @@ const config: Config = {
deep: '#c62828',
soft: '#fff0ee',
},
+ bg: '#FFF8EB',
+ card: '#FFF0D2',
+ primary: '#FF5D68',
+ teal: '#00B4A5',
+ gold: '#FFBE1E',
+ orange: '#FF8C1E',
+ purple: '#B450DC',
+ green: '#3CC864',
+ 'text-bright': '#262630',
+ muted: '#A07864',
+ 'status-bg': '#FF5D68',
},
fontFamily: {
sans: ['var(--font-geist-sans)', '-apple-system', 'BlinkMacSystemFont', 'sans-serif'],