diff --git a/ui/.eslintrc.json b/ui/.eslintrc.json new file mode 100644 index 00000000..2988de07 --- /dev/null +++ b/ui/.eslintrc.json @@ -0,0 +1,11 @@ +{ + "extends": [ + "next", + "next/core-web-vitals", + "eslint:recommended", + "plugin:react/recommended", + "plugin:@typescript-eslint/recommended", + "prettier" + ], + "plugins": ["react", "@typescript-eslint"] +} diff --git a/ui/.gitignore b/ui/.gitignore new file mode 100644 index 00000000..5ef6a520 --- /dev/null +++ b/ui/.gitignore @@ -0,0 +1,41 @@ +# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. + +# dependencies +/node_modules +/.pnp +.pnp.* +.yarn/* +!.yarn/patches +!.yarn/plugins +!.yarn/releases +!.yarn/versions + +# testing +/coverage + +# next.js +/.next/ +/out/ + +# production +/build + +# misc +.DS_Store +*.pem + +# debug +npm-debug.log* +yarn-debug.log* +yarn-error.log* +.pnpm-debug.log* + +# env files (can opt-in for committing if needed) +.env* + +# vercel +.vercel + +# typescript +*.tsbuildinfo +next-env.d.ts diff --git a/ui/.prettierrc.json b/ui/.prettierrc.json new file mode 100644 index 00000000..f5a575dc --- /dev/null +++ b/ui/.prettierrc.json @@ -0,0 +1,8 @@ +{ + "trailingComma": "es5", + "semi": true, + "tabWidth": 2, + "singleQuote": true, + "jsxSingleQuote": true, + "plugins": ["prettier-plugin-tailwindcss"] +} diff --git a/ui/Dockerfile b/ui/Dockerfile new file mode 100644 index 00000000..732ba1fc --- /dev/null +++ b/ui/Dockerfile @@ -0,0 +1,38 @@ +FROM node:23-alpine AS base + +FROM base AS deps +RUN apk add --no-cache libc6-compat +WORKDIR /app + +COPY package.json yarn.lock* package-lock.json* pnpm-lock.yaml* .npmrc* ./ +RUN npm i + +FROM base AS builder +WORKDIR /app +COPY --from=deps /app/node_modules ./node_modules +COPY . . + +ENV NEXT_TELEMETRY_DISABLED=1 + +RUN npm run build +FROM base AS runner +WORKDIR /app + +ENV NODE_ENV=production + +RUN addgroup --system --gid 1001 nodejs +RUN adduser --system --uid 1001 nextjs + +COPY --from=builder /app/public ./public + +COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./ +COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static + +USER nextjs + +EXPOSE 3000 + +ENV PORT=3000 + +ENV HOSTNAME="0.0.0.0" +CMD ["node", "server.js"] diff --git a/ui/README.md b/ui/README.md new file mode 100644 index 00000000..e215bc4c --- /dev/null +++ b/ui/README.md @@ -0,0 +1,36 @@ +This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app). + +## Getting Started + +First, run the development server: + +```bash +npm run dev +# or +yarn dev +# or +pnpm dev +# or +bun dev +``` + +Open [http://localhost:3000](http://localhost:3000) with your browser to see the result. + +You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file. + +This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel. + +## Learn More + +To learn more about Next.js, take a look at the following resources: + +- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API. +- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial. + +You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome! + +## Deploy on Vercel + +The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js. + +Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details. diff --git a/ui/app/_register/page.tsx b/ui/app/_register/page.tsx new file mode 100644 index 00000000..f88bf940 --- /dev/null +++ b/ui/app/_register/page.tsx @@ -0,0 +1,172 @@ +'use client'; + +import { useAuth } from '@/lib/auth/AuthContext'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { useRouter } from 'next/navigation'; +import { useState } from 'react'; +import { toast } from 'sonner'; +import Link from 'next/link'; +import { ArrowUpCircleIcon } from 'lucide-react'; +import { registerUser, SchemaRegisterProps } from '@/lib/api/services/auth'; + +export default function NostrRegisterPage() { + const router = useRouter(); + const { connectNostr } = useAuth(); + const [isLoading, setIsLoading] = useState(false); + const [formData, setFormData] = useState({ + npub: '', + name: '', + }); + + const handleInputChange = (e: React.ChangeEvent) => { + const { name, value } = e.target; + setFormData((prev) => ({ ...prev, [name]: value })); + }; + + const handleNostrConnect = async () => { + setIsLoading(true); + try { + const publicKey = await connectNostr(); + if (publicKey) { + setFormData((prev) => ({ ...prev, npub: publicKey })); + toast.success( + 'Nostr connected. Please enter your name to complete registration.' + ); + } else { + toast.error( + 'Failed to connect. Please make sure your Nostr extension is installed and enabled.' + ); + } + } catch (error) { + console.error('Nostr connection error:', error); + toast.error('Failed to connect to Nostr. Please try again.'); + } finally { + setIsLoading(false); + } + }; + + const handleRegister = async (e: React.FormEvent) => { + e.preventDefault(); + + if (!formData.npub || formData.npub.length < 10) { + toast.error('Please enter a valid Nostr public key'); + return; + } + + if (!formData.name) { + toast.error('Please enter your name'); + return; + } + + setIsLoading(true); + try { + // Register the user + const result = await registerUser(formData); + console.log('Registration successful:', result); + toast.success('Account created successfully'); + router.push('/login'); + } catch (error) { + console.error('Registration error:', error); + toast.error('Registration failed. Please try again.'); + } finally { + setIsLoading(false); + } + }; + + return ( +
+
+
+
+
+
+ +
+ +
+ Routstr + +

Create an Account

+
+ Already have an account?{' '} + + Sign in + +
+
+
+
+ + +
+
+ + +
+ +
+
+ + Or + +
+
+ +
+
+
+
+ By clicking create account, you agree to our{' '} + Terms of Service and{' '} + Privacy Policy. +
+
+
+
+ ); +} diff --git a/ui/app/data.json b/ui/app/data.json new file mode 100644 index 00000000..ec087364 --- /dev/null +++ b/ui/app/data.json @@ -0,0 +1,614 @@ +[ + { + "id": 1, + "header": "Cover page", + "type": "Cover page", + "status": "In Process", + "target": "18", + "limit": "5", + "reviewer": "Eddie Lake" + }, + { + "id": 2, + "header": "Table of contents", + "type": "Table of contents", + "status": "Done", + "target": "29", + "limit": "24", + "reviewer": "Eddie Lake" + }, + { + "id": 3, + "header": "Executive summary", + "type": "Narrative", + "status": "Done", + "target": "10", + "limit": "13", + "reviewer": "Eddie Lake" + }, + { + "id": 4, + "header": "Technical approach", + "type": "Narrative", + "status": "Done", + "target": "27", + "limit": "23", + "reviewer": "Jamik Tashpulatov" + }, + { + "id": 5, + "header": "Design", + "type": "Narrative", + "status": "In Process", + "target": "2", + "limit": "16", + "reviewer": "Jamik Tashpulatov" + }, + { + "id": 6, + "header": "Capabilities", + "type": "Narrative", + "status": "In Process", + "target": "20", + "limit": "8", + "reviewer": "Jamik Tashpulatov" + }, + { + "id": 7, + "header": "Integration with existing systems", + "type": "Narrative", + "status": "In Process", + "target": "19", + "limit": "21", + "reviewer": "Jamik Tashpulatov" + }, + { + "id": 8, + "header": "Innovation and Advantages", + "type": "Narrative", + "status": "Done", + "target": "25", + "limit": "26", + "reviewer": "Assign reviewer" + }, + { + "id": 9, + "header": "Overview of EMR's Innovative Solutions", + "type": "Technical content", + "status": "Done", + "target": "7", + "limit": "23", + "reviewer": "Assign reviewer" + }, + { + "id": 10, + "header": "Advanced Algorithms and Machine Learning", + "type": "Narrative", + "status": "Done", + "target": "30", + "limit": "28", + "reviewer": "Assign reviewer" + }, + { + "id": 11, + "header": "Adaptive Communication Protocols", + "type": "Narrative", + "status": "Done", + "target": "9", + "limit": "31", + "reviewer": "Assign reviewer" + }, + { + "id": 12, + "header": "Advantages Over Current Technologies", + "type": "Narrative", + "status": "Done", + "target": "12", + "limit": "0", + "reviewer": "Assign reviewer" + }, + { + "id": 13, + "header": "Past Performance", + "type": "Narrative", + "status": "Done", + "target": "22", + "limit": "33", + "reviewer": "Assign reviewer" + }, + { + "id": 14, + "header": "Customer Feedback and Satisfaction Levels", + "type": "Narrative", + "status": "Done", + "target": "15", + "limit": "34", + "reviewer": "Assign reviewer" + }, + { + "id": 15, + "header": "Implementation Challenges and Solutions", + "type": "Narrative", + "status": "Done", + "target": "3", + "limit": "35", + "reviewer": "Assign reviewer" + }, + { + "id": 16, + "header": "Security Measures and Data Protection Policies", + "type": "Narrative", + "status": "In Process", + "target": "6", + "limit": "36", + "reviewer": "Assign reviewer" + }, + { + "id": 17, + "header": "Scalability and Future Proofing", + "type": "Narrative", + "status": "Done", + "target": "4", + "limit": "37", + "reviewer": "Assign reviewer" + }, + { + "id": 18, + "header": "Cost-Benefit Analysis", + "type": "Plain language", + "status": "Done", + "target": "14", + "limit": "38", + "reviewer": "Assign reviewer" + }, + { + "id": 19, + "header": "User Training and Onboarding Experience", + "type": "Narrative", + "status": "Done", + "target": "17", + "limit": "39", + "reviewer": "Assign reviewer" + }, + { + "id": 20, + "header": "Future Development Roadmap", + "type": "Narrative", + "status": "Done", + "target": "11", + "limit": "40", + "reviewer": "Assign reviewer" + }, + { + "id": 21, + "header": "System Architecture Overview", + "type": "Technical content", + "status": "In Process", + "target": "24", + "limit": "18", + "reviewer": "Maya Johnson" + }, + { + "id": 22, + "header": "Risk Management Plan", + "type": "Narrative", + "status": "Done", + "target": "15", + "limit": "22", + "reviewer": "Carlos Rodriguez" + }, + { + "id": 23, + "header": "Compliance Documentation", + "type": "Legal", + "status": "In Process", + "target": "31", + "limit": "27", + "reviewer": "Sarah Chen" + }, + { + "id": 24, + "header": "API Documentation", + "type": "Technical content", + "status": "Done", + "target": "8", + "limit": "12", + "reviewer": "Raj Patel" + }, + { + "id": 25, + "header": "User Interface Mockups", + "type": "Visual", + "status": "In Process", + "target": "19", + "limit": "25", + "reviewer": "Leila Ahmadi" + }, + { + "id": 26, + "header": "Database Schema", + "type": "Technical content", + "status": "Done", + "target": "22", + "limit": "20", + "reviewer": "Thomas Wilson" + }, + { + "id": 27, + "header": "Testing Methodology", + "type": "Technical content", + "status": "In Process", + "target": "17", + "limit": "14", + "reviewer": "Assign reviewer" + }, + { + "id": 28, + "header": "Deployment Strategy", + "type": "Narrative", + "status": "Done", + "target": "26", + "limit": "30", + "reviewer": "Eddie Lake" + }, + { + "id": 29, + "header": "Budget Breakdown", + "type": "Financial", + "status": "In Process", + "target": "13", + "limit": "16", + "reviewer": "Jamik Tashpulatov" + }, + { + "id": 30, + "header": "Market Analysis", + "type": "Research", + "status": "Done", + "target": "29", + "limit": "32", + "reviewer": "Sophia Martinez" + }, + { + "id": 31, + "header": "Competitor Comparison", + "type": "Research", + "status": "In Process", + "target": "21", + "limit": "19", + "reviewer": "Assign reviewer" + }, + { + "id": 32, + "header": "Maintenance Plan", + "type": "Technical content", + "status": "Done", + "target": "16", + "limit": "23", + "reviewer": "Alex Thompson" + }, + { + "id": 33, + "header": "User Personas", + "type": "Research", + "status": "In Process", + "target": "27", + "limit": "24", + "reviewer": "Nina Patel" + }, + { + "id": 34, + "header": "Accessibility Compliance", + "type": "Legal", + "status": "Done", + "target": "18", + "limit": "21", + "reviewer": "Assign reviewer" + }, + { + "id": 35, + "header": "Performance Metrics", + "type": "Technical content", + "status": "In Process", + "target": "23", + "limit": "26", + "reviewer": "David Kim" + }, + { + "id": 36, + "header": "Disaster Recovery Plan", + "type": "Technical content", + "status": "Done", + "target": "14", + "limit": "17", + "reviewer": "Jamik Tashpulatov" + }, + { + "id": 37, + "header": "Third-party Integrations", + "type": "Technical content", + "status": "In Process", + "target": "25", + "limit": "28", + "reviewer": "Eddie Lake" + }, + { + "id": 38, + "header": "User Feedback Summary", + "type": "Research", + "status": "Done", + "target": "20", + "limit": "15", + "reviewer": "Assign reviewer" + }, + { + "id": 39, + "header": "Localization Strategy", + "type": "Narrative", + "status": "In Process", + "target": "12", + "limit": "19", + "reviewer": "Maria Garcia" + }, + { + "id": 40, + "header": "Mobile Compatibility", + "type": "Technical content", + "status": "Done", + "target": "28", + "limit": "31", + "reviewer": "James Wilson" + }, + { + "id": 41, + "header": "Data Migration Plan", + "type": "Technical content", + "status": "In Process", + "target": "19", + "limit": "22", + "reviewer": "Assign reviewer" + }, + { + "id": 42, + "header": "Quality Assurance Protocols", + "type": "Technical content", + "status": "Done", + "target": "30", + "limit": "33", + "reviewer": "Priya Singh" + }, + { + "id": 43, + "header": "Stakeholder Analysis", + "type": "Research", + "status": "In Process", + "target": "11", + "limit": "14", + "reviewer": "Eddie Lake" + }, + { + "id": 44, + "header": "Environmental Impact Assessment", + "type": "Research", + "status": "Done", + "target": "24", + "limit": "27", + "reviewer": "Assign reviewer" + }, + { + "id": 45, + "header": "Intellectual Property Rights", + "type": "Legal", + "status": "In Process", + "target": "17", + "limit": "20", + "reviewer": "Sarah Johnson" + }, + { + "id": 46, + "header": "Customer Support Framework", + "type": "Narrative", + "status": "Done", + "target": "22", + "limit": "25", + "reviewer": "Jamik Tashpulatov" + }, + { + "id": 47, + "header": "Version Control Strategy", + "type": "Technical content", + "status": "In Process", + "target": "15", + "limit": "18", + "reviewer": "Assign reviewer" + }, + { + "id": 48, + "header": "Continuous Integration Pipeline", + "type": "Technical content", + "status": "Done", + "target": "26", + "limit": "29", + "reviewer": "Michael Chen" + }, + { + "id": 49, + "header": "Regulatory Compliance", + "type": "Legal", + "status": "In Process", + "target": "13", + "limit": "16", + "reviewer": "Assign reviewer" + }, + { + "id": 50, + "header": "User Authentication System", + "type": "Technical content", + "status": "Done", + "target": "28", + "limit": "31", + "reviewer": "Eddie Lake" + }, + { + "id": 51, + "header": "Data Analytics Framework", + "type": "Technical content", + "status": "In Process", + "target": "21", + "limit": "24", + "reviewer": "Jamik Tashpulatov" + }, + { + "id": 52, + "header": "Cloud Infrastructure", + "type": "Technical content", + "status": "Done", + "target": "16", + "limit": "19", + "reviewer": "Assign reviewer" + }, + { + "id": 53, + "header": "Network Security Measures", + "type": "Technical content", + "status": "In Process", + "target": "29", + "limit": "32", + "reviewer": "Lisa Wong" + }, + { + "id": 54, + "header": "Project Timeline", + "type": "Planning", + "status": "Done", + "target": "14", + "limit": "17", + "reviewer": "Eddie Lake" + }, + { + "id": 55, + "header": "Resource Allocation", + "type": "Planning", + "status": "In Process", + "target": "27", + "limit": "30", + "reviewer": "Assign reviewer" + }, + { + "id": 56, + "header": "Team Structure and Roles", + "type": "Planning", + "status": "Done", + "target": "20", + "limit": "23", + "reviewer": "Jamik Tashpulatov" + }, + { + "id": 57, + "header": "Communication Protocols", + "type": "Planning", + "status": "In Process", + "target": "15", + "limit": "18", + "reviewer": "Assign reviewer" + }, + { + "id": 58, + "header": "Success Metrics", + "type": "Planning", + "status": "Done", + "target": "30", + "limit": "33", + "reviewer": "Eddie Lake" + }, + { + "id": 59, + "header": "Internationalization Support", + "type": "Technical content", + "status": "In Process", + "target": "23", + "limit": "26", + "reviewer": "Jamik Tashpulatov" + }, + { + "id": 60, + "header": "Backup and Recovery Procedures", + "type": "Technical content", + "status": "Done", + "target": "18", + "limit": "21", + "reviewer": "Assign reviewer" + }, + { + "id": 61, + "header": "Monitoring and Alerting System", + "type": "Technical content", + "status": "In Process", + "target": "25", + "limit": "28", + "reviewer": "Daniel Park" + }, + { + "id": 62, + "header": "Code Review Guidelines", + "type": "Technical content", + "status": "Done", + "target": "12", + "limit": "15", + "reviewer": "Eddie Lake" + }, + { + "id": 63, + "header": "Documentation Standards", + "type": "Technical content", + "status": "In Process", + "target": "27", + "limit": "30", + "reviewer": "Jamik Tashpulatov" + }, + { + "id": 64, + "header": "Release Management Process", + "type": "Planning", + "status": "Done", + "target": "22", + "limit": "25", + "reviewer": "Assign reviewer" + }, + { + "id": 65, + "header": "Feature Prioritization Matrix", + "type": "Planning", + "status": "In Process", + "target": "19", + "limit": "22", + "reviewer": "Emma Davis" + }, + { + "id": 66, + "header": "Technical Debt Assessment", + "type": "Technical content", + "status": "Done", + "target": "24", + "limit": "27", + "reviewer": "Eddie Lake" + }, + { + "id": 67, + "header": "Capacity Planning", + "type": "Planning", + "status": "In Process", + "target": "21", + "limit": "24", + "reviewer": "Jamik Tashpulatov" + }, + { + "id": 68, + "header": "Service Level Agreements", + "type": "Legal", + "status": "Done", + "target": "26", + "limit": "29", + "reviewer": "Assign reviewer" + } +] diff --git a/ui/app/favicon.ico b/ui/app/favicon.ico new file mode 100644 index 00000000..718d6fea Binary files /dev/null and b/ui/app/favicon.ico differ diff --git a/ui/app/globals.css b/ui/app/globals.css new file mode 100644 index 00000000..86093a5c --- /dev/null +++ b/ui/app/globals.css @@ -0,0 +1,136 @@ +@import 'tailwindcss'; +@import 'tw-animate-css'; + +@custom-variant dark (&:is(.dark *)); + +@theme inline { + --color-background: var(--background); + --color-foreground: var(--foreground); + --font-sans: var(--font-geist-sans); + --font-mono: var(--font-geist-mono); + --color-sidebar-ring: var(--sidebar-ring); + --color-sidebar-border: var(--sidebar-border); + --color-sidebar-accent-foreground: var(--sidebar-accent-foreground); + --color-sidebar-accent: var(--sidebar-accent); + --color-sidebar-primary-foreground: var(--sidebar-primary-foreground); + --color-sidebar-primary: var(--sidebar-primary); + --color-sidebar-foreground: var(--sidebar-foreground); + --color-sidebar: var(--sidebar); + --color-chart-5: var(--chart-5); + --color-chart-4: var(--chart-4); + --color-chart-3: var(--chart-3); + --color-chart-2: var(--chart-2); + --color-chart-1: var(--chart-1); + --color-ring: var(--ring); + --color-input: var(--input); + --color-border: var(--border); + --color-destructive: var(--destructive); + --color-accent-foreground: var(--accent-foreground); + --color-accent: var(--accent); + --color-muted-foreground: var(--muted-foreground); + --color-muted: var(--muted); + --color-secondary-foreground: var(--secondary-foreground); + --color-secondary: var(--secondary); + --color-primary-foreground: var(--primary-foreground); + --color-primary: var(--primary); + --color-popover-foreground: var(--popover-foreground); + --color-popover: var(--popover); + --color-card-foreground: var(--card-foreground); + --color-card: var(--card); + --radius-sm: calc(var(--radius) - 4px); + --radius-md: calc(var(--radius) - 2px); + --radius-lg: var(--radius); + --radius-xl: calc(var(--radius) + 4px); +} + +:root { + --radius: 0.625rem; + --background: oklch(1 0 0); + --foreground: oklch(0.147 0.004 49.25); + --card: oklch(1 0 0); + --card-foreground: oklch(0.147 0.004 49.25); + --popover: oklch(1 0 0); + --popover-foreground: oklch(0.147 0.004 49.25); + --primary: oklch(0.216 0.006 56.043); + --primary-foreground: oklch(0.985 0.001 106.423); + --secondary: oklch(0.97 0.001 106.424); + --secondary-foreground: oklch(0.216 0.006 56.043); + --muted: oklch(0.97 0.001 106.424); + --muted-foreground: oklch(0.553 0.013 58.071); + --accent: oklch(0.97 0.001 106.424); + --accent-foreground: oklch(0.216 0.006 56.043); + --destructive: oklch(0.577 0.245 27.325); + --border: oklch(0.923 0.003 48.717); + --input: oklch(0.923 0.003 48.717); + --ring: oklch(0.709 0.01 56.259); + --chart-1: oklch(0.646 0.222 41.116); + --chart-2: oklch(0.6 0.118 184.704); + --chart-3: oklch(0.398 0.07 227.392); + --chart-4: oklch(0.828 0.189 84.429); + --chart-5: oklch(0.769 0.188 70.08); + --sidebar: oklch(0.985 0.001 106.423); + --sidebar-foreground: oklch(0.147 0.004 49.25); + --sidebar-primary: oklch(0.216 0.006 56.043); + --sidebar-primary-foreground: oklch(0.985 0.001 106.423); + --sidebar-accent: oklch(0.97 0.001 106.424); + --sidebar-accent-foreground: oklch(0.216 0.006 56.043); + --sidebar-border: oklch(0.923 0.003 48.717); + --sidebar-ring: oklch(0.709 0.01 56.259); +} + +.dark { + --background: oklch(0.147 0.004 49.25); + --foreground: oklch(0.985 0.001 106.423); + --card: oklch(0.216 0.006 56.043); + --card-foreground: oklch(0.985 0.001 106.423); + --popover: oklch(0.216 0.006 56.043); + --popover-foreground: oklch(0.985 0.001 106.423); + --primary: oklch(0.923 0.003 48.717); + --primary-foreground: oklch(0.216 0.006 56.043); + --secondary: oklch(0.268 0.007 34.298); + --secondary-foreground: oklch(0.985 0.001 106.423); + --muted: oklch(0.268 0.007 34.298); + --muted-foreground: oklch(0.709 0.01 56.259); + --accent: oklch(0.268 0.007 34.298); + --accent-foreground: oklch(0.985 0.001 106.423); + --destructive: oklch(0.704 0.191 22.216); + --border: oklch(1 0 0 / 10%); + --input: oklch(1 0 0 / 15%); + --ring: oklch(0.553 0.013 58.071); + --chart-1: oklch(0.488 0.243 264.376); + --chart-2: oklch(0.696 0.17 162.48); + --chart-3: oklch(0.769 0.188 70.08); + --chart-4: oklch(0.627 0.265 303.9); + --chart-5: oklch(0.645 0.246 16.439); + --sidebar: oklch(0.216 0.006 56.043); + --sidebar-foreground: oklch(0.985 0.001 106.423); + --sidebar-primary: oklch(0.488 0.243 264.376); + --sidebar-primary-foreground: oklch(0.985 0.001 106.423); + --sidebar-accent: oklch(0.268 0.007 34.298); + --sidebar-accent-foreground: oklch(0.985 0.001 106.423); + --sidebar-border: oklch(1 0 0 / 10%); + --sidebar-ring: oklch(0.553 0.013 58.071); +} + +@layer base { + * { + @apply border-border outline-ring/50; + } + body { + @apply bg-background text-foreground; + } +} + +/* Custom animations */ +@keyframes shimmer { + 0% { + transform: translateX(-100%); + } + 100% { + transform: translateX(100%); + } +} + +.animate-shimmer { + animation: shimmer 2s infinite; +} diff --git a/ui/app/layout.tsx b/ui/app/layout.tsx new file mode 100644 index 00000000..a20063b3 --- /dev/null +++ b/ui/app/layout.tsx @@ -0,0 +1,35 @@ +import type { Metadata } from 'next'; +import { Geist, Geist_Mono } from 'next/font/google'; +import './globals.css'; +import { Providers } from './providers'; + +const geistSans = Geist({ + variable: '--font-geist-sans', + subsets: ['latin'], +}); + +const geistMono = Geist_Mono({ + variable: '--font-geist-mono', + subsets: ['latin'], +}); + +export const metadata: Metadata = { + title: 'Routstr', + description: 'Routstr model management', +}; + +export default function RootLayout({ + children, +}: Readonly<{ + children: React.ReactNode; +}>) { + return ( + + + {children} + + + ); +} diff --git a/ui/app/login/page.tsx b/ui/app/login/page.tsx new file mode 100644 index 00000000..93b911b8 --- /dev/null +++ b/ui/app/login/page.tsx @@ -0,0 +1,82 @@ +'use client'; + +import { useState, useEffect } from 'react'; +import { useRouter } from 'next/navigation'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from '@/components/ui/card'; +import { adminLogin } from '@/lib/api/services/auth'; +import { ConfigurationService } from '@/lib/api/services/configuration'; +import { toast } from 'sonner'; + +export default function AdminLoginPage() { + const router = useRouter(); + const [password, setPassword] = useState(''); + const [isLoading, setIsLoading] = useState(false); + + useEffect(() => { + if (ConfigurationService.isTokenValid()) { + router.push('/'); + } + }, [router]); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + + if (!password) { + toast.error('Please enter your password'); + return; + } + + setIsLoading(true); + try { + await adminLogin(password); + toast.success('Successfully logged in'); + router.push('/'); + } catch (error) { + console.error('Login error:', error); + toast.error('Invalid password. Please try again.'); + } finally { + setIsLoading(false); + } + }; + + return ( +
+ + + + Admin Login + + + Enter your admin password to access the dashboard + + + +
+
+ setPassword(e.target.value)} + disabled={isLoading} + autoFocus + required + /> +
+ +
+
+
+
+ ); +} diff --git a/ui/app/models/page.tsx b/ui/app/models/page.tsx new file mode 100644 index 00000000..23f60c2a --- /dev/null +++ b/ui/app/models/page.tsx @@ -0,0 +1,258 @@ +'use client'; + +import { ModelSelector } from '@/components/ModelSelector'; +import { ModelTester } from '@/components/ModelTester'; +import { ApiEndpointTester } from '@/components/ApiEndpointTester'; +import { SidebarInset, SidebarProvider } from '@/components/ui/sidebar'; +import { AppSidebar } from '@/components/app-sidebar'; +import { SiteHeader } from '@/components/site-header'; +import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; +import { useQuery } from '@tanstack/react-query'; +import { AdminService } from '@/lib/api/services/admin'; +import { Skeleton } from '@/components/ui/skeleton'; +import { AlertCircle, Users, Globe } from 'lucide-react'; +import { Alert, AlertDescription } from '@/components/ui/alert'; +import { Badge } from '@/components/ui/badge'; +import { useMemo } from 'react'; + +export default function ModelsPage() { + const { + data: modelsData, + isLoading: isLoadingModels, + error: modelsError, + } = useQuery({ + queryKey: ['admin-models-with-providers'], + queryFn: () => AdminService.getModelsWithProviders(), + refetchOnWindowFocus: false, + }); + + const { models = [], groups = [] } = modelsData || {}; + + const groupedModels = useMemo(() => { + if (!models) return {}; + + return models.reduce>((acc, model) => { + const provider = model.provider; + if (!acc[provider]) { + acc[provider] = []; + } + acc[provider].push(model); + return acc; + }, {}); + }, [models]); + + const groupDataMap = useMemo(() => { + return new Map(groups.map((group) => [group.provider, group])); + }, [groups]); + + const providerInfo = useMemo(() => { + return Object.entries(groupedModels).map(([provider, providerModels]) => { + const groupData = groupDataMap.get(provider); + const activeModels = providerModels.filter((m) => !m.soft_deleted).length; + const totalModels = providerModels.length; + + return { + provider, + activeModels, + totalModels, + groupData, + hasGroupUrl: !!groupData?.group_url, + hasGroupApiKey: !!groupData?.group_api_key, + }; + }); + }, [groupedModels, groupDataMap]); + + return ( + + + + +
+
+
+

+ Model Management & API Testing +

+
+ + + + Manage Models + {/*Basic Testing + API Endpoints */} + + + +
+ Manage your AI models organized by provider groups. Configure + API keys, and organize models by provider groups. +
+ + {isLoadingModels ? ( +
+ + +
+ ) : modelsError ? ( + + + + Failed to load models. Please try refreshing the page. + + + ) : ( + +
+ {/* Provider Tabs Navigation */} +
+ + + + All Models + + {models.length} + + + {providerInfo.map( + ({ provider, activeModels, totalModels }) => ( + + + {provider} +
+ + {activeModels}/{totalModels} + +
+
+ ) + )} +
+
+ + {/* All Models Tab */} + +
+
+ Overview of all models across all provider groups. +
+ +
+
+ + {Object.entries(groupedModels).map( + ([provider, providerModels]) => { + const groupData = groupDataMap.get(provider); + + return ( + +
+
+
+

+ + {provider} +

+
+ {providerModels.filter( + (m) => m.soft_deleted + ).length > 0 && ( + + { + providerModels.filter( + (m) => m.soft_deleted + ).length + }{' '} + soft deleted + + )} + {groupData?.group_url && ( + + + {groupData.group_url} + + )} +
+
+
+ +
+
+ ); + } + )} +
+
+ )} +
+ + +
+ Test model credentials and connectivity with basic chat + completion requests through the secure proxy (resolves CORS + and Docker network issues). Models can be tested even without + API keys configured (useful for free models or when + authentication is handled elsewhere). +
+ + {isLoadingModels ? ( +
+ + +
+ ) : modelsError ? ( + + + + Failed to load models for testing. Please try refreshing + the page. + + + ) : ( + + )} +
+ + +
+ Comprehensive testing of all OpenAI API endpoints including + chat completions, embeddings, image generation, audio + synthesis, and model listing through the secure proxy + (resolves CORS and Docker network issues). Models can be + tested with or without API keys configured. +
+ + {isLoadingModels ? ( +
+ + +
+ ) : modelsError ? ( + + + + Failed to load models for API testing. Please try + refreshing the page. + + + ) : ( + + )} +
+
+
+
+
+
+ ); +} diff --git a/ui/app/page.tsx b/ui/app/page.tsx new file mode 100644 index 00000000..fde61885 --- /dev/null +++ b/ui/app/page.tsx @@ -0,0 +1,31 @@ +import { AppSidebar } from '@/components/app-sidebar'; +import { SiteHeader } from '@/components/site-header'; +import { SidebarInset, SidebarProvider } from '@/components/ui/sidebar'; +import { DetailedWalletBalance } from '@/components/detailed-wallet-balance'; + +export default function Page() { + return ( + + + + +
+
+

+ Admin Dashboard +

+

+ Monitor and manage wallet balances +

+
+ +
+
+ +
+
+
+
+
+ ); +} diff --git a/ui/app/providers.tsx b/ui/app/providers.tsx new file mode 100644 index 00000000..af801e33 --- /dev/null +++ b/ui/app/providers.tsx @@ -0,0 +1,39 @@ +'use client'; + +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { ReactQueryDevtools } from '@tanstack/react-query-devtools'; +import { useState, type ReactNode } from 'react'; +import { Toaster } from 'sonner'; +import { AuthProvider } from '@/lib/auth/AuthContext'; +import { ProtectedRoute } from '@/lib/auth/ProtectedRoute'; + +interface ProvidersProps { + children: ReactNode; +} + +export function Providers({ children }: ProvidersProps) { + const [queryClient] = useState( + () => + new QueryClient({ + defaultOptions: { + queries: { + staleTime: 1000 * 60 * 5, + refetchOnWindowFocus: false, + retry: 2, + }, + }, + }) + ); + + return ( + + + + {children} + + + + + + ); +} diff --git a/ui/app/providers/page.tsx b/ui/app/providers/page.tsx new file mode 100644 index 00000000..64339515 --- /dev/null +++ b/ui/app/providers/page.tsx @@ -0,0 +1,690 @@ +'use client'; + +import { SidebarInset, SidebarProvider } from '@/components/ui/sidebar'; +import { AppSidebar } from '@/components/app-sidebar'; +import { SiteHeader } from '@/components/site-header'; +import { Button } from '@/components/ui/button'; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from '@/components/ui/card'; +import { Badge } from '@/components/ui/badge'; +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import { + AdminService, + UpstreamProvider, + CreateUpstreamProvider, + UpdateUpstreamProvider, +} from '@/lib/api/services/admin'; +import { Skeleton } from '@/components/ui/skeleton'; +import { + AlertCircle, + Plus, + Pencil, + Trash2, + Server, + Eye, + EyeOff, + Database, + ChevronDown, + ChevronUp, +} from 'lucide-react'; +import { Alert, AlertDescription } from '@/components/ui/alert'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from '@/components/ui/dialog'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select'; +import { Switch } from '@/components/ui/switch'; +import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; +import { useState } from 'react'; +import { toast } from 'sonner'; + +export default function ProvidersPage() { + const queryClient = useQueryClient(); + const [editingProvider, setEditingProvider] = + useState(null); + const [isCreateDialogOpen, setIsCreateDialogOpen] = useState(false); + const [isEditDialogOpen, setIsEditDialogOpen] = useState(false); + const [showApiKey, setShowApiKey] = useState(null); + const [expandedProviders, setExpandedProviders] = useState>( + new Set() + ); + const [viewingModels, setViewingModels] = useState(null); + + const [formData, setFormData] = useState({ + provider_type: 'openrouter', + base_url: '', + api_key: '', + api_version: null, + enabled: true, + }); + + const { + data: providers = [], + isLoading, + error, + } = useQuery({ + queryKey: ['upstream-providers'], + queryFn: () => AdminService.getUpstreamProviders(), + refetchOnWindowFocus: false, + }); + + const { data: providerModels, isLoading: isLoadingModels } = useQuery({ + queryKey: ['provider-models', viewingModels], + queryFn: () => + viewingModels + ? AdminService.getProviderModels(viewingModels) + : Promise.resolve(null), + enabled: !!viewingModels, + refetchOnWindowFocus: false, + }); + + const createMutation = useMutation({ + mutationFn: (data: CreateUpstreamProvider) => + AdminService.createUpstreamProvider(data), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['upstream-providers'] }); + setIsCreateDialogOpen(false); + toast.success('Provider created successfully'); + resetForm(); + }, + onError: (error: Error) => { + toast.error(`Failed to create provider: ${error.message}`); + }, + }); + + const updateMutation = useMutation({ + mutationFn: ({ id, data }: { id: number; data: UpdateUpstreamProvider }) => + AdminService.updateUpstreamProvider(id, data), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['upstream-providers'] }); + setIsEditDialogOpen(false); + setEditingProvider(null); + toast.success('Provider updated successfully'); + }, + onError: (error: Error) => { + toast.error(`Failed to update provider: ${error.message}`); + }, + }); + + const deleteMutation = useMutation({ + mutationFn: (id: number) => AdminService.deleteUpstreamProvider(id), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['upstream-providers'] }); + toast.success('Provider deleted successfully'); + }, + onError: (error: Error) => { + toast.error(`Failed to delete provider: ${error.message}`); + }, + }); + + const resetForm = () => { + setFormData({ + provider_type: 'openrouter', + base_url: '', + api_key: '', + api_version: null, + enabled: true, + }); + }; + + const handleCreate = () => { + createMutation.mutate(formData); + }; + + const handleEdit = (provider: UpstreamProvider) => { + setEditingProvider(provider); + setFormData({ + provider_type: provider.provider_type, + base_url: provider.base_url, + api_key: '', + api_version: provider.api_version || null, + enabled: provider.enabled, + }); + setIsEditDialogOpen(true); + }; + + const handleUpdate = () => { + if (!editingProvider) return; + const updateData: UpdateUpstreamProvider = { + provider_type: formData.provider_type, + base_url: formData.base_url, + api_version: formData.api_version, + enabled: formData.enabled, + }; + if (formData.api_key) { + updateData.api_key = formData.api_key; + } + updateMutation.mutate({ id: editingProvider.id, data: updateData }); + }; + + const handleDelete = (id: number) => { + if (confirm('Are you sure you want to delete this provider?')) { + deleteMutation.mutate(id); + } + }; + + const getDefaultBaseUrl = (type: string) => { + const defaults: Record = { + openrouter: 'https://openrouter.ai/api/v1', + openai: 'https://api.openai.com/v1', + anthropic: 'https://api.anthropic.com/v1', + azure: '', + generic: '', + }; + return defaults[type] || ''; + }; + + const toggleProviderExpansion = (providerId: number) => { + const newExpanded = new Set(expandedProviders); + if (newExpanded.has(providerId)) { + newExpanded.delete(providerId); + } else { + newExpanded.add(providerId); + } + setExpandedProviders(newExpanded); + if (!newExpanded.has(providerId)) { + setViewingModels(null); + } else { + setViewingModels(providerId); + } + }; + + return ( + + + + +
+
+
+
+

+ Upstream Providers +

+

+ Manage your AI provider connections and credentials +

+
+ + + + + + + Add Upstream Provider + + Configure a new AI provider connection + + +
+
+ + +
+
+ + + setFormData({ ...formData, base_url: e.target.value }) + } + placeholder='https://api.example.com/v1' + /> +
+
+ + + setFormData({ ...formData, api_key: e.target.value }) + } + placeholder='sk-...' + /> +
+ {formData.provider_type === 'azure' && ( +
+ + + setFormData({ + ...formData, + api_version: e.target.value || null, + }) + } + placeholder='2024-02-15-preview' + /> +
+ )} +
+ + setFormData({ ...formData, enabled: checked }) + } + /> + +
+
+ + + + +
+
+
+ + {isLoading ? ( +
+ + +
+ ) : error ? ( + + + + Failed to load providers. Please try refreshing the page. + + + ) : providers.length === 0 ? ( + + + +

+ No providers configured +

+

+ Get started by adding your first upstream provider +

+ +
+
+ ) : ( +
+ {providers.map((provider) => ( + + +
+
+
+ + {provider.provider_type} + + + {provider.enabled ? 'Enabled' : 'Disabled'} + +
+ + {provider.base_url} + +
+
+ + + +
+
+
+ +
+
+
+ + Provider ID: + + {provider.id} +
+ {provider.api_version && ( +
+ + API Version: + + + {provider.api_version} + +
+ )} +
+ + API Key: + +
+ + {showApiKey === provider.id + ? provider.api_key || '[REDACTED]' + : '••••••••'} + + +
+
+
+ + {expandedProviders.has(provider.id) && ( +
+ {isLoadingModels && + viewingModels === provider.id ? ( +
+ + +
+ ) : providerModels && + viewingModels === provider.id ? ( + + + + Database Models + + {providerModels.db_models.length} + + + + Remote Models + + {providerModels.remote_models.length} + + + + + {providerModels.db_models.length === 0 ? ( +
+ No database models configured +
+ ) : ( +
+ {providerModels.db_models.map((model) => ( +
+
+
+ + {model.id} + + + {model.enabled + ? 'Enabled' + : 'Disabled'} + +
+
+ {model.description || model.name} +
+
+
+ {model.context_length?.toLocaleString()}{' '} + tokens +
+
+ ))} +
+ )} +
+ + {providerModels.remote_models.length === 0 ? ( +
+ No remote models available +
+ ) : ( +
+ {providerModels.remote_models.map( + (model) => ( +
+
+
+ {model.id} +
+
+ {model.description || + model.name} +
+
+
+ {model.context_length?.toLocaleString()}{' '} + tokens +
+
+ ) + )} +
+ )} +
+
+ ) : null} +
+ )} +
+
+
+ ))} +
+ )} +
+
+ + + + + Edit Upstream Provider + + Update provider configuration + + +
+
+ + +
+
+ + + setFormData({ ...formData, base_url: e.target.value }) + } + placeholder='https://api.example.com/v1' + /> +
+
+ + + setFormData({ ...formData, api_key: e.target.value }) + } + placeholder='Leave blank to keep current' + /> +
+ {formData.provider_type === 'azure' && ( +
+ + + setFormData({ + ...formData, + api_version: e.target.value || null, + }) + } + placeholder='2024-02-15-preview' + /> +
+ )} +
+ + setFormData({ ...formData, enabled: checked }) + } + /> + +
+
+ + + + +
+
+
+
+ ); +} diff --git a/ui/app/settings/page.tsx b/ui/app/settings/page.tsx new file mode 100644 index 00000000..e2b512ed --- /dev/null +++ b/ui/app/settings/page.tsx @@ -0,0 +1,36 @@ +'use client'; + +import * as React from 'react'; +import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; +import { ServerConfigSettings } from '@/components/settings/server-config-settings'; +import { SiteHeader } from '@/components/site-header'; +import { AppSidebar } from '@/components/app-sidebar'; +import { SidebarInset, SidebarProvider } from '@/components/ui/sidebar'; +import { Toaster } from 'sonner'; + +export default function SettingsPage() { + return ( + + + + +
+
+
+

Settings

+
+ + + Server Configuration + + + + + +
+
+
+ +
+ ); +} diff --git a/ui/app/transactions/page.tsx b/ui/app/transactions/page.tsx new file mode 100644 index 00000000..973c30bb --- /dev/null +++ b/ui/app/transactions/page.tsx @@ -0,0 +1,353 @@ +'use client'; + +import { useState } from 'react'; +import { SidebarInset, SidebarProvider } from '@/components/ui/sidebar'; +import { AppSidebar } from '@/components/app-sidebar'; +import { SiteHeader } from '@/components/site-header'; +import { useQuery } from '@tanstack/react-query'; +import { Skeleton } from '@/components/ui/skeleton'; +import { + AlertCircle, + Copy, + RefreshCw, + ChevronLeft, + ChevronRight, +} from 'lucide-react'; +import { Alert, AlertDescription } from '@/components/ui/alert'; +import { Button } from '@/components/ui/button'; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from '@/components/ui/table'; +import { Badge } from '@/components/ui/badge'; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from '@/components/ui/tooltip'; +import { toast } from 'sonner'; +import { apiClient } from '@/lib/api/client'; + +interface Transaction { + id: string; + created_at: string; + token: string; + amount: string; +} + +interface PaginatedTransactionsResponse { + transactions: Transaction[]; + total: number; + page: number; + per_page: number; + total_pages: number; +} + +const TransactionService = { + getAllTransactions: async (): Promise => { + try { + const response = await apiClient.get('/api/transactions'); + return response || []; + } catch (error) { + console.error('Failed to fetch transactions:', error); + throw new Error('Failed to fetch transactions'); + } + }, + + getPaginatedTransactions: async ( + page: number, + perPage: number + ): Promise => { + try { + const response = await apiClient.get( + `/api/transactions/paginated/${page}/${perPage}` + ); + return response; + } catch (error) { + console.error('Failed to fetch paginated transactions:', error); + throw new Error('Failed to fetch paginated transactions'); + } + }, + + getRecentTransactions: async (limit: number): Promise => { + try { + const response = await apiClient.get( + `/api/transactions/recent/${limit}` + ); + return response || []; + } catch (error) { + console.error('Failed to fetch recent transactions:', error); + throw new Error('Failed to fetch recent transactions'); + } + }, +}; + +export default function TransactionsPage() { + const [currentPage, setCurrentPage] = useState(1); + const perPage = 20; + + // Fetch paginated transactions data + const { + data: paginationData, + isLoading, + error, + refetch, + } = useQuery({ + queryKey: ['transactions', currentPage, perPage], + queryFn: () => + TransactionService.getPaginatedTransactions(currentPage, perPage), + refetchOnWindowFocus: false, + retry: 1, + staleTime: 30000, // 30 seconds + }); + + const transactions = paginationData?.transactions || []; + const totalPages = paginationData?.total_pages || 0; + const total = paginationData?.total || 0; + + const formatDate = (dateString: string) => { + return new Date(dateString).toLocaleString(); + }; + + const formatAmount = (amount: string) => { + return `${parseInt(amount).toLocaleString()} msats`; + }; + + const truncateToken = (token: string) => { + if (token.length <= 20) return token; + return `${token.slice(0, 10)}...${token.slice(-10)}`; + }; + + const copyToClipboard = async (text: string) => { + try { + await navigator.clipboard.writeText(text); + toast.success('Token copied to clipboard!'); + } catch (error) { + console.error('Failed to copy to clipboard:', error); + toast.error('Failed to copy token'); + } + }; + + const goToPage = (page: number) => { + if (page >= 1 && page <= totalPages) { + setCurrentPage(page); + } + }; + + const goToPrevious = () => { + if (currentPage > 1) { + setCurrentPage(currentPage - 1); + } + }; + + const goToNext = () => { + if (currentPage < totalPages) { + setCurrentPage(currentPage + 1); + } + }; + + return ( + + + + + +
+
+
+
+

+ Transaction History +

+

+ View all Cashu token transactions processed by the system +

+
+ +
+ + {isLoading ? ( +
+ {[...Array(5)].map((_, i) => ( + + ))} +
+ ) : error ? ( + + + + Failed to load transactions.{' '} + {error instanceof Error + ? error.message + : 'Please check if the server is running and try refreshing the page.'} + + + ) : transactions.length === 0 ? ( +
+

+ No transactions found. +

+
+ ) : ( +
+
+
+ Showing {(currentPage - 1) * perPage + 1} to{' '} + {Math.min(currentPage * perPage, total)} of {total}{' '} + transactions +
+
+ Page {currentPage} of {totalPages} +
+
+ +
+ + + + ID + Date & Time + Amount + + Cashu Token + + Actions + + + + {transactions.map((transaction) => ( + + + {transaction.id.slice(0, 8)} + + +
+ {formatDate(transaction.created_at)} +
+
+ + + {formatAmount(transaction.amount)} + + + +
+ + +

+ {truncateToken(transaction.token)} +

+
+ +

+ {transaction.token} +

+
+
+
+
+ + + +
+ ))} +
+
+
+ + {/* Pagination Controls */} + {totalPages > 1 && ( +
+
+ Page {currentPage} of {totalPages} +
+
+ + + {/* Page Numbers */} +
+ {Array.from( + { length: Math.min(5, totalPages) }, + (_, i) => { + const pageNumber = + currentPage <= 3 + ? i + 1 + : currentPage >= totalPages - 2 + ? totalPages - 4 + i + : currentPage - 2 + i; + + if (pageNumber < 1 || pageNumber > totalPages) + return null; + + return ( + + ); + } + )} +
+ + +
+
+ )} +
+ )} +
+
+
+
+
+ ); +} diff --git a/ui/app/unauthorized/page.tsx b/ui/app/unauthorized/page.tsx new file mode 100644 index 00000000..2fe8f4bc --- /dev/null +++ b/ui/app/unauthorized/page.tsx @@ -0,0 +1,32 @@ +'use client'; + +import { Button } from '@/components/ui/button'; +import { useRouter } from 'next/navigation'; +import { ShieldAlertIcon } from 'lucide-react'; + +export default function UnauthorizedPage() { + const router = useRouter(); + + return ( +
+
+ + +

Access Denied

+ +

+ You don't have permission to access this page. Please contact + your administrator if you believe this is an error. +

+ +
+ + + +
+
+
+ ); +} diff --git a/ui/components.json b/ui/components.json new file mode 100644 index 00000000..3d296c4f --- /dev/null +++ b/ui/components.json @@ -0,0 +1,21 @@ +{ + "$schema": "https://ui.shadcn.com/schema.json", + "style": "new-york", + "rsc": true, + "tsx": true, + "tailwind": { + "config": "", + "css": "app/globals.css", + "baseColor": "stone", + "cssVariables": true, + "prefix": "" + }, + "aliases": { + "components": "@/components", + "utils": "@/lib/utils", + "ui": "@/components/ui", + "lib": "@/lib", + "hooks": "@/hooks" + }, + "iconLibrary": "lucide" +} diff --git a/ui/components/AddModelForm.tsx b/ui/components/AddModelForm.tsx new file mode 100644 index 00000000..005fdaab --- /dev/null +++ b/ui/components/AddModelForm.tsx @@ -0,0 +1,319 @@ +'use client'; + +import React, { useState } from 'react'; +import { useForm } from 'react-hook-form'; +import { zodResolver } from '@hookform/resolvers/zod'; +import { ManualModelSchema, type ManualModel } from '@/lib/api/schemas/models'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Textarea } from '@/components/ui/textarea'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select'; +import { + Form, + FormControl, + FormDescription, + FormField, + FormItem, + FormLabel, + FormMessage, +} from '@/components/ui/form'; +import { Plus, Loader2 } from 'lucide-react'; +import { toast } from 'sonner'; + +interface AddModelFormProps { + onModelAdd: (model: ManualModel) => void; + onCancel?: () => void; + isOpen: boolean; +} + +export function AddModelForm({ + onModelAdd, + onCancel, + isOpen, +}: AddModelFormProps) { + const [isSubmitting, setIsSubmitting] = useState(false); + + const form = useForm({ + resolver: zodResolver(ManualModelSchema) as any, // eslint-disable-line @typescript-eslint/no-explicit-any + defaultValues: { + name: '', + full_name: '', + input_cost: 0, + output_cost: 0, + provider: '', + modelType: 'text' as const, + description: '', + contextLength: undefined, + }, + }); + + const onSubmit = async (data: ManualModel) => { + setIsSubmitting(true); + try { + await onModelAdd(data); + toast.success('Model added successfully!'); + form.reset(); + onCancel?.(); + } catch (error) { + toast.error('Failed to add model. Please try again.'); + console.error('Error adding model:', error); + } finally { + setIsSubmitting(false); + } + }; + + const handleClose = () => { + if (!isSubmitting) { + form.reset(); + onCancel?.(); + } + }; + + return ( + + + + + + Add New Model + + + Manually add a new AI model to your collection + + +
+ +
+ ( + + Model Name * + + + + + Display name for the model + + + + )} + /> + + ( + + Provider * + + + + + AI model provider or company + + + + )} + /> +
+ +
+ ( + + Input Cost (per 1M tokens) * + + { + const value = parseFloat(e.target.value) || 0; + const rounded = Math.round(value * 1000) / 1000; + field.onChange(rounded); + }} + className='w-full' + /> + + + Cost in USD per 1,000,000 input tokens (max 3 decimals) + + + + )} + /> + + ( + + Output Cost (per 1M tokens) * + + { + const value = parseFloat(e.target.value) || 0; + const rounded = Math.round(value * 1000) / 1000; + field.onChange(rounded); + }} + className='w-full' + /> + + + Cost in USD per 1,000,000 output tokens (max 3 decimals) + + + + )} + /> +
+ +
+ ( + + Model Type + + + Type of AI model functionality + + + + )} + /> + + ( + + Context Length + + { + const val = parseInt(e.target.value); + field.onChange( + isNaN(val) || val === 0 ? undefined : val + ); + }} + className='w-full' + /> + + + Maximum context length in tokens (optional, leave empty + for default) + + + + )} + /> +
+ + ( + + Description + +