diff --git a/.attach_pid1264587 b/.attach_pid1264587 new file mode 100644 index 0000000..e69de29 diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index b4e72e9..831d399 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -37,13 +37,13 @@ jobs: uses: actions/setup-java@v2 with: distribution: 'temurin' - java-version: '11' + java-version: '17' - name: Run tests - run: mvn test + run: ./gradlew test - - name: Build with Maven - run: mvn clean package + - name: Build with Gradle + run: ./gradlew clean build - name: Set up Docker Buildx uses: docker/setup-buildx-action@v3 diff --git a/.github/workflows/deploy-frontend.yml b/.github/workflows/deploy-frontend.yml index b58232c..e681bfc 100644 --- a/.github/workflows/deploy-frontend.yml +++ b/.github/workflows/deploy-frontend.yml @@ -1,60 +1,39 @@ -# Sample workflow for building and deploying a Next.js site to GitHub Pages -# -# To get started with Next.js see: https://nextjs.org/docs/getting-started -# name: Deploy Next.js site to Pages on: - # Runs on pushes targeting the default branch push: - branches: ["gh-page"] - - # Allows you to run this workflow manually from the Actions tab + branches: [ "gh-page" ] workflow_dispatch: -# Sets permissions of the GITHUB_TOKEN to allow deployment to GitHub Pages permissions: contents: read pages: write id-token: write -# Allow only one concurrent deployment, skipping runs queued between the run in-progress and latest queued. -# However, do NOT cancel in-progress runs as we want to allow these production deployments to complete. concurrency: group: "pages" cancel-in-progress: false jobs: - # Build job build: runs-on: ubuntu-latest + defaults: + run: + working-directory: ./src/frontend + steps: - name: Checkout uses: actions/checkout@v4 - - name: Detect package manager - id: detect-package-manager - run: | - if [ -f "${{ github.workspace }}/yarn.lock" ]; then - echo "manager=yarn" >> $GITHUB_OUTPUT - echo "command=install" >> $GITHUB_OUTPUT - echo "runner=yarn" >> $GITHUB_OUTPUT - exit 0 - elif [ -f "${{ github.workspace }}/package.json" ]; then - echo "manager=npm" >> $GITHUB_OUTPUT - echo "command=ci" >> $GITHUB_OUTPUT - echo "runner=npx --no-install" >> $GITHUB_OUTPUT - exit 0 - else - echo "Unable to determine package manager" - exit 1 - fi - - name: Setup Node uses: actions/setup-node@v4 with: node-version: "lts/*" - cache: ${{ steps.detect-package-manager.outputs.manager }} + + - name: Create .npmrc for authentication + run: | + echo "//npm.pkg.github.com/:_authToken=${{ secrets.NPM_TOKEN }}" >> ~/.npmrc + echo "@ts4nfdi:registry=https://npm.pkg.github.com" >> ~/.npmrc - name: Setup Pages uses: actions/configure-pages@v4 @@ -64,24 +43,24 @@ jobs: with: path: | .next/cache - # Generate a new cache whenever packages or source files change. - key: ${{ runner.os }}-nextjs-${{ hashFiles('**/package-lock.json', '**/yarn.lock') }}-${{ hashFiles('**.[jt]s', '**.[jt]sx') }} - # If source files changed but packages didn't, rebuild from a prior cache. + key: ${{ runner.os }}-nextjs-${{ hashFiles('**/package-lock.json') }}-${{ hashFiles('**.[jt]s', '**.[jt]sx') }} restore-keys: | - ${{ runner.os }}-nextjs-${{ hashFiles('**/package-lock.json', '**/yarn.lock') }}- + ${{ runner.os }}-nextjs-${{ hashFiles('**/package-lock.json') }}- - name: Install dependencies - run: ${{ steps.detect-package-manager.outputs.manager }} ${{ steps.detect-package-manager.outputs.command }} + run: npm install --legacy-peer-deps - name: Build with Next.js - run: ${{ steps.detect-package-manager.outputs.runner }} next build + run: npm run build + + - name: ls + run: ls -l - name: Upload artifact uses: actions/upload-pages-artifact@v3 with: - path: ./out + path: /home/runner/work/api-gateway/api-gateway/src/frontend/out - # Deployment job deploy: environment: name: github-pages diff --git a/.gitignore b/.gitignore index 832f709..d600570 100644 --- a/.gitignore +++ b/.gitignore @@ -43,3 +43,9 @@ bin/ src/frontend/node_modules/ src/frontend/.next/ + +_next/ + +src/frontend/.env + +node_modules/ diff --git a/Dockerfile b/Dockerfile index a8f4c0c..6682964 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,8 +1,6 @@ FROM openjdk:11-jre RUN rm -rf /usr/local/tomcat/webapps/* RUN mkdir -p /logs -COPY ./target/API-Gateway-0.0.1-SNAPSHOT.jar /usr/local/tomcat/webapps/API-Gateway-0.0.1-SNAPSHOT.jar +COPY ./build/libs/api-gateway-1.0-SNAPSHOT.jar /usr/local/tomcat/webapps/API-Gateway-0.0.1-SNAPSHOT.jar EXPOSE 8080 CMD ["sh","-c", "java -jar /usr/local/tomcat/webapps/API-Gateway-0.0.1-SNAPSHOT.jar"] - - diff --git a/build.gradle b/build.gradle index 99bd58b..66ae76e 100644 --- a/build.gradle +++ b/build.gradle @@ -47,6 +47,11 @@ dependencies { // Swagger UI implementation group: 'org.springdoc', name: 'springdoc-openapi-starter-webmvc-ui', version: '2.6.0' + // Jwt authentification + implementation 'io.jsonwebtoken:jjwt-api:0.11.5' + runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.11.5' + runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.11.5' + // Tests testImplementation 'org.springframework.boot:spring-boot-starter-test' testRuntimeOnly 'com.h2database:h2' diff --git a/docker-compose.yaml b/docker-compose.yaml index 8f2cd01..cd02ced 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -1,5 +1,4 @@ -version: '3.8' - +version: '3.9' services: api-gateway-backend: build: @@ -8,11 +7,37 @@ services: container_name: api-gateway ports: - "8080:8080" + restart: always + profiles: + - all + depends_on: + - postgres environment: ONTOPORTAL_APIKEY: "put here APIKEY" + SPRING_DATASOURCE_URL: jdbc:postgresql://0.0.0.0:5432/db + POSTGRES_PASSWORD: password + POSTGRES_USER: backend + SPRING_JPA_HIBERNATE_DDL_AUTO: update + + postgres: + image: postgres restart: always + environment: + - POSTGRES_DB=db + - POSTGRES_PASSWORD=password + - POSTGRES_USER=developer + ports: + - "5432:5432" networks: - - api-gateway + - network + adminer: + image: adminer + restart: always + ports: + - "8081:8080" + networks: + - network networks: - api-gateway: + network: + driver: bridge diff --git a/src/frontend/.env.sample b/src/frontend/.env.sample new file mode 100644 index 0000000..2568a30 --- /dev/null +++ b/src/frontend/.env.sample @@ -0,0 +1 @@ +API_GATEWAY_URL=https://ts4nfdi-api-gateway.prod.km.k8s.zbmed.de/api-gateway diff --git a/src/frontend/.eslintrc.json b/src/frontend/.eslintrc.json new file mode 100644 index 0000000..13015d6 --- /dev/null +++ b/src/frontend/.eslintrc.json @@ -0,0 +1,6 @@ +{ + "extends": "next/core-web-vitals", + "rules": { + "@typescript-eslint/no-explicit-any": "off" + } +} diff --git a/src/frontend/.gitignore b/src/frontend/.gitignore new file mode 100644 index 0000000..fd3dbb5 --- /dev/null +++ b/src/frontend/.gitignore @@ -0,0 +1,36 @@ +# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. + +# dependencies +/node_modules +/.pnp +.pnp.js +.yarn/install-state.gz + +# testing +/coverage + +# next.js +/.next/ +/out/ + +# production +/build + +# misc +.DS_Store +*.pem + +# debug +npm-debug.log* +yarn-debug.log* +yarn-error.log* + +# local env files +.env*.local + +# vercel +.vercel + +# typescript +*.tsbuildinfo +next-env.d.ts diff --git a/src/frontend/README.md b/src/frontend/README.md new file mode 100644 index 0000000..d596ffd --- /dev/null +++ b/src/frontend/README.md @@ -0,0 +1,24 @@ +# Terminology Service Widgets Demonstration +A simple Next.js project to demonstrate the integration of the Terminology Service Suite widgets. + +For this demo, a Next.js app was set up in Pycharm with `Node.js v18.17.0`. + +## Install and run the demo application + +1) Install + +```bash +npm install --legacy-peer-deps +``` + +2) Run +```bash +npm run dev +``` + +## Implementation + +Click [here](https://ts4nfdi.github.io/terminology-service-suite/comp/latest/) for detailed instructions on how to implement the +package. + +A sample integration is shown in `MainPage.tsx`. \ No newline at end of file diff --git a/src/frontend/app/Header.tsx b/src/frontend/app/Header.tsx new file mode 100644 index 0000000..7b5f8e8 --- /dev/null +++ b/src/frontend/app/Header.tsx @@ -0,0 +1,66 @@ +import React from 'react'; +import {Button} from '@/components/ui/button'; +import {Card, CardContent, CardDescription, CardHeader, CardTitle} from '@/components/ui/card'; +import {ExternalLinkIcon} from "lucide-react"; +import Link from "next/link"; + +const PageHeader = () => { + const isLoggedIn = false; //TODO Replace with your auth logic + + return ( +
+
+
+
+ Icon +

TSNFDI API Gateway

+
+ + {isLoggedIn ? ( + + My Profile + + ) : ( + + )} +
+
+ +
+ + + Welcome to TSNFDI API Gateway + + The TS4NFDI Federated Service is an advanced, dynamic solution designed to perform federated + calls across multiple Terminology Services (TS) within NFDI. It is particularly tailored for + environments where integration and aggregation of diverse data sources are essential. The + service offers search capabilities, enabling users to refine search results based on + specific criteria, and supports responses in both JSON and JSON-LD formats. + + + + + + +
+

+ Access the API endpoint for documentation and usage +

+
+ +
+
+
+
+
+
+
+ ); +}; + +export default PageHeader; diff --git a/src/frontend/app/MainPage.tsx b/src/frontend/app/MainPage.tsx new file mode 100644 index 0000000..75822f2 --- /dev/null +++ b/src/frontend/app/MainPage.tsx @@ -0,0 +1,35 @@ +import React from 'react'; +import {CardContent, CardHeader} from "@/components/ui/card"; +import PageHeader from './Header'; +import {Separator} from "@/components/ui/separator"; +import ArtefactsTable from "@/app/home/browse/BrowseResources"; +import Autocomplete from "@/app/home/search/AutoComplete"; + + +export function MainPage({apiUrl}: { apiUrl: string }) { + return (<> + +
+
+ +

Find a concept

+
+ + + +
+ +
+
+ +

Browse resources

+
+ + + +
+
+
+ + ) +} diff --git a/src/frontend/app/auth/login/page.tsx b/src/frontend/app/auth/login/page.tsx new file mode 100644 index 0000000..7c6755f --- /dev/null +++ b/src/frontend/app/auth/login/page.tsx @@ -0,0 +1,14 @@ +import {Card, CardHeader} from "@/components/ui/card"; + +export default function login() { + return ( +
+ + + Not yet enabled, come back soon to use authentification + + +
+ ) + +} diff --git a/src/frontend/app/favicon.ico b/src/frontend/app/favicon.ico new file mode 100644 index 0000000..718d6fe Binary files /dev/null and b/src/frontend/app/favicon.ico differ diff --git a/src/frontend/app/globals.css b/src/frontend/app/globals.css new file mode 100644 index 0000000..51fadf5 --- /dev/null +++ b/src/frontend/app/globals.css @@ -0,0 +1,77 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; + +.main-panel { + width: 70%; +} + +.euiListGroupItem__label { + display: block; + width: 100%; +} + +@layer base { + :root { + --background: 0 0% 100%; + --foreground: 0 0% 3.9%; + --card: 0 0% 100%; + --card-foreground: 0 0% 3.9%; + --popover: 0 0% 100%; + --popover-foreground: 0 0% 3.9%; + --primary: 0 0% 9%; + --primary-foreground: 0 0% 98%; + --secondary: 0 0% 96.1%; + --secondary-foreground: 0 0% 9%; + --muted: 0 0% 96.1%; + --muted-foreground: 0 0% 45.1%; + --accent: 0 0% 96.1%; + --accent-foreground: 0 0% 9%; + --destructive: 0 84.2% 60.2%; + --destructive-foreground: 0 0% 98%; + --border: 0 0% 89.8%; + --input: 0 0% 89.8%; + --ring: 0 0% 3.9%; + --chart-1: 12 76% 61%; + --chart-2: 173 58% 39%; + --chart-3: 197 37% 24%; + --chart-4: 43 74% 66%; + --chart-5: 27 87% 67%; + --radius: 0.5rem; + } + .dark { + --background: 0 0% 3.9%; + --foreground: 0 0% 98%; + --card: 0 0% 3.9%; + --card-foreground: 0 0% 98%; + --popover: 0 0% 3.9%; + --popover-foreground: 0 0% 98%; + --primary: 0 0% 98%; + --primary-foreground: 0 0% 9%; + --secondary: 0 0% 14.9%; + --secondary-foreground: 0 0% 98%; + --muted: 0 0% 14.9%; + --muted-foreground: 0 0% 63.9%; + --accent: 0 0% 14.9%; + --accent-foreground: 0 0% 98%; + --destructive: 0 62.8% 30.6%; + --destructive-foreground: 0 0% 98%; + --border: 0 0% 14.9%; + --input: 0 0% 14.9%; + --ring: 0 0% 83.1%; + --chart-1: 220 70% 50%; + --chart-2: 160 60% 45%; + --chart-3: 30 80% 55%; + --chart-4: 280 65% 60%; + --chart-5: 340 75% 55%; + } +} + +@layer base { + * { + @apply border-border; + } + body { + @apply bg-background text-foreground; + } +} diff --git a/src/frontend/app/home/browse/BrowseCard.tsx b/src/frontend/app/home/browse/BrowseCard.tsx new file mode 100644 index 0000000..03e58c9 --- /dev/null +++ b/src/frontend/app/home/browse/BrowseCard.tsx @@ -0,0 +1,29 @@ +import {Card, CardHeader, CardTitle} from "@/components/ui/card"; +import {Badge} from "@/components/ui/badge"; +import React from "react"; + +export const BrowseCard = ({title, tags, onTagClick, sourceUrl}: any) => { + return ( + window.open(sourceUrl, '_blank', 'noopener,noreferrer')}> + +
+ + {title} + +
+ {tags.map((tag: any, index: number) => ( + onTagClick?.(tag)} + > + {tag} + + ))} +
+
+
+
+ ); +}; diff --git a/src/frontend/app/home/browse/BrowseResources.tsx b/src/frontend/app/home/browse/BrowseResources.tsx new file mode 100644 index 0000000..4494f4a --- /dev/null +++ b/src/frontend/app/home/browse/BrowseResources.tsx @@ -0,0 +1,186 @@ +import React, {useEffect, useRef, useState} from 'react'; +import {Loader} from "@/components/Loading"; +import {Input} from "@/components/ui/input"; +import ModalContainer from "@/lib/modal"; +import MultipleSelector, {SelectorOption} from "@/components/MultipleSelector"; +import {BrowseCard} from "@/app/home/browse/BrowseCard"; +import {PaginatedCardList} from "@/components/PaginatedCardList"; + +type Artefact = { + label: string; + description?: string; + backend_type: string; + source_name: string; + source: string; +}; + +type ResponseConfig = { + databases: Array<{ url: string; responseTime: number }>; + totalResponseTime: number; +}; + +function prettyMilliseconds(ms: number): string { + const seconds = Math.floor(ms / 1000); + const minutes = Math.floor(seconds / 60); + const hours = Math.floor(minutes / 60); + + const remainingMilliseconds = ms % 1000; + const remainingSeconds = seconds % 60; + const remainingMinutes = minutes % 60; + + let result = ""; + + if (hours) result += `${hours}h `; + if (remainingMinutes) result += `${remainingMinutes}m `; + if (remainingSeconds) result += `${remainingSeconds}s `; + if (remainingMilliseconds && ms < 1000) result += `${remainingMilliseconds}ms`; + + return result.trim(); +} + +type ArtefactsTableProps = { + apiUrl: string; +}; + +const ArtefactsTable: React.FC = ({apiUrl}) => { + const [items, setItems] = useState([]); + const [responseConfig, setResponseConfig] = useState({ + databases: [], + totalResponseTime: 0, + }); + const [loading, setLoading] = useState(true); + const [sortField, setSortField] = useState("label"); + const [sortDirection, setSortDirection] = useState<"asc" | "desc">("asc"); + const [pageIndex, setPageIndex] = useState(0); + const [pageSize, setPageSize] = useState(10); + const [searchQuery, setSearchQuery] = useState(""); + const [selectedSources, setSelectedSources] = useState([]); + const [sourceOptions, setSourceOptions] = useState([]); + const [isModalOpen, setIsModalOpen] = useState(false); + const [selectedObject, setSelectedObject] = useState(null); + const isInitialMount = useRef(true); + + let [filteredItems, setFilteredItems] = useState([]); + + const fetchArtefacts = async () => { + try { + const response = await fetch(apiUrl); + const responseJson = await response.json(); + const data: Artefact[] = responseJson.collection; + + let uniqueSourceNames: any[] = data.map((item) => item.source_name); + // @ts-ignore + uniqueSourceNames = [...new Set(uniqueSourceNames)] + uniqueSourceNames = uniqueSourceNames.map(x => { + return {label: x, value: x} + }).concat() + + setItems(data); + setResponseConfig(responseJson.responseConfig); + setSourceOptions(uniqueSourceNames); + } catch (error) { + console.error("Error fetching artefacts:", error); + } finally { + setLoading(false); + } + }; + + useEffect(() => { + if (isInitialMount.current) { + fetchArtefacts(); + isInitialMount.current = false; + } + }, [apiUrl]); + + + useEffect(() => { + let filtered = items.filter((item) => { + const matchesSearch = + item.label.toLowerCase().includes(searchQuery.toLowerCase()) || + item.description?.toString().toLowerCase().includes(searchQuery.toLowerCase()); + + const matchesSource = + selectedSources.length === 0 || selectedSources.includes(item.source_name); + + return matchesSearch && matchesSource; + }); + setFilteredItems(filtered); + }, [searchQuery, items, selectedSources]); + + /* + const groupedBySourceName = filteredItems.reduce>((acc, item) => { + const count = (acc[item.source_name]?.count || 0) + 1; + const time = responseConfig.databases.find((x) => x.url.includes(item.source))?.responseTime; + + acc[item.source_name] = {count, time}; + + return acc; + }, {});*/ + + const openModal = (item: Artefact) => { + setSelectedObject(item); + setIsModalOpen(true); + }; + + const closeModal = () => { + setSelectedObject(null); + setIsModalOpen(false); + }; + + if (loading) { + return ; + } + + return ( +
+
+ setSearchQuery(e.target.value)} + /> +
+ setSelectedSources(e.map((o: any) => o.value))} + emptyIndicator={ +

+ no results found. +

+ } + /> +
+ {/* +

+ Results: {filteredItems.length} ({prettyMilliseconds(responseConfig.totalResponseTime)}) +

+ +
    + {Object.entries(groupedBySourceName).map(([sourceName, {count, time}]) => ( +
  • + {sourceName}: {count} {count > 1 ? "results" : "result"} ({prettyMilliseconds(time || 0)}) +
  • + ))} +
*/} +
+ + { + return {title: `${x.description} (${x.label})`, sourceUrl: x.source_url, tags: [x.source_name]}; + }).concat()} + CardComponent={(props: any) => ( + { + }}/> + )}/> + + +
+ ); +}; + +export default ArtefactsTable; diff --git a/src/frontend/app/home/search/AutoComplete.tsx b/src/frontend/app/home/search/AutoComplete.tsx new file mode 100644 index 0000000..7e3bef9 --- /dev/null +++ b/src/frontend/app/home/search/AutoComplete.tsx @@ -0,0 +1,103 @@ +import { + EuiFieldSearch, + EuiFieldText, + EuiFlexGroup, + EuiFlexItem, + EuiFormRow, + EuiListGroup, + EuiListGroupItem, + EuiSpacer, + EuiStat +} from "@elastic/eui"; +import React from "react"; +import {useSearch} from "@/lib/search"; +import ModalContainer, {useModal} from "@/lib/modal"; +import {AutoCompleteResult} from "@/app/home/search/AutoCompleteResult"; +import {Loader} from "@/components/Loading"; + + +export default function Autocomplete(props: { apiUrl: string }) { + const { + suggestions, + inputValue, + responseTime, + isLoading, + errorMessage, + handleInputChange, + handleApiUrlChange + } = useSearch(props); + + const {isModalOpen, selectedObject, openModal, closeModal} = useModal(); + + return ( + <> + + + + + + + + + { + suggestions.length > 0 && !isLoading && !errorMessage && ( + + + + + + + + + ) + } + + + + + + {isLoading && ( + + )} + + + {errorMessage && ( + +

Error: {errorMessage}

+
+ )} + + + {suggestions.length > 0 && !isLoading && !errorMessage && ( + <> + + + {suggestions.map((suggestion: any, index) => ( + openModal(suggestion)} key={index} + style={{width: '100%'}} + label={}/> + ))} + + + + + )} + +
+ + + + + ); +} diff --git a/src/frontend/app/home/search/AutoCompleteResult.tsx b/src/frontend/app/home/search/AutoCompleteResult.tsx new file mode 100644 index 0000000..ccdca55 --- /dev/null +++ b/src/frontend/app/home/search/AutoCompleteResult.tsx @@ -0,0 +1,19 @@ +import {EuiBadge, EuiFlexGroup, EuiFlexItem} from "@elastic/eui"; + +export function AutoCompleteResult(props: { suggestion: any }) { + + return <> + + +
{props.suggestion.label}
+
+ + + {props.suggestion.backend_type} + {props.suggestion.ontology} + {props.suggestion.short_form} + + +
+ +} \ No newline at end of file diff --git a/src/frontend/app/layout.tsx b/src/frontend/app/layout.tsx new file mode 100644 index 0000000..73a68ac --- /dev/null +++ b/src/frontend/app/layout.tsx @@ -0,0 +1,22 @@ +import type { Metadata } from 'next' +import { Inter } from 'next/font/google' +import './globals.css' + +const inter = Inter({ subsets: ['latin'] }) + +export const metadata: Metadata = { + title: 'Widgets Demo', + description: 'Widgets demo', +} + +export default function RootLayout({ + children, +}: { + children: React.ReactNode +}) { + return ( + + {children} + + ) +} diff --git a/src/frontend/app/page.module.css b/src/frontend/app/page.module.css new file mode 100644 index 0000000..6676d2c --- /dev/null +++ b/src/frontend/app/page.module.css @@ -0,0 +1,229 @@ +.main { + display: flex; + flex-direction: column; + justify-content: space-between; + align-items: center; + padding: 6rem; + min-height: 100vh; +} + +.description { + display: inherit; + justify-content: inherit; + align-items: inherit; + font-size: 0.85rem; + max-width: var(--max-width); + width: 100%; + z-index: 2; + font-family: var(--font-mono); +} + +.description a { + display: flex; + justify-content: center; + align-items: center; + gap: 0.5rem; +} + +.description p { + position: relative; + margin: 0; + padding: 1rem; + background-color: rgba(var(--callout-rgb), 0.5); + border: 1px solid rgba(var(--callout-border-rgb), 0.3); + border-radius: var(--border-radius); +} + +.code { + font-weight: 700; + font-family: var(--font-mono); +} + +.grid { + display: grid; + grid-template-columns: repeat(4, minmax(25%, auto)); + max-width: 100%; + width: var(--max-width); +} + +.card { + padding: 1rem 1.2rem; + border-radius: var(--border-radius); + background: rgba(var(--card-rgb), 0); + border: 1px solid rgba(var(--card-border-rgb), 0); + transition: background 200ms, border 200ms; +} + +.card span { + display: inline-block; + transition: transform 200ms; +} + +.card h2 { + font-weight: 600; + margin-bottom: 0.7rem; +} + +.card p { + margin: 0; + opacity: 0.6; + font-size: 0.9rem; + line-height: 1.5; + max-width: 30ch; +} + +.center { + display: flex; + justify-content: center; + align-items: center; + position: relative; + padding: 4rem 0; +} + +.center::before { + background: var(--secondary-glow); + border-radius: 50%; + width: 480px; + height: 360px; + margin-left: -400px; +} + +.center::after { + background: var(--primary-glow); + width: 240px; + height: 180px; + z-index: -1; +} + +.center::before, +.center::after { + content: ''; + left: 50%; + position: absolute; + filter: blur(45px); + transform: translateZ(0); +} + +.logo { + position: relative; +} +/* Enable hover only on non-touch devices */ +@media (hover: hover) and (pointer: fine) { + .card:hover { + background: rgba(var(--card-rgb), 0.1); + border: 1px solid rgba(var(--card-border-rgb), 0.15); + } + + .card:hover span { + transform: translateX(4px); + } +} + +@media (prefers-reduced-motion) { + .card:hover span { + transform: none; + } +} + +/* Mobile */ +@media (max-width: 700px) { + .content { + padding: 4rem; + } + + .grid { + grid-template-columns: 1fr; + margin-bottom: 120px; + max-width: 320px; + text-align: center; + } + + .card { + padding: 1rem 2.5rem; + } + + .card h2 { + margin-bottom: 0.5rem; + } + + .center { + padding: 8rem 0 6rem; + } + + .center::before { + transform: none; + height: 300px; + } + + .description { + font-size: 0.8rem; + } + + .description a { + padding: 1rem; + } + + .description p, + .description div { + display: flex; + justify-content: center; + position: fixed; + width: 100%; + } + + .description p { + align-items: center; + inset: 0 0 auto; + padding: 2rem 1rem 1.4rem; + border-radius: 0; + border: none; + border-bottom: 1px solid rgba(var(--callout-border-rgb), 0.25); + background: linear-gradient( + to bottom, + rgba(var(--background-start-rgb), 1), + rgba(var(--callout-rgb), 0.5) + ); + background-clip: padding-box; + backdrop-filter: blur(24px); + } + + .description div { + align-items: flex-end; + pointer-events: none; + inset: auto 0 0; + padding: 2rem; + height: 200px; + background: linear-gradient( + to bottom, + transparent 0%, + rgb(var(--background-end-rgb)) 40% + ); + z-index: 1; + } +} + +/* Tablet and Smaller Desktop */ +@media (min-width: 701px) and (max-width: 1120px) { + .grid { + grid-template-columns: repeat(2, 50%); + } +} + +@media (prefers-color-scheme: dark) { + .vercelLogo { + filter: invert(1); + } + + .logo { + filter: invert(1) drop-shadow(0 0 0.3rem #ffffff70); + } +} + +@keyframes rotate { + from { + transform: rotate(360deg); + } + to { + transform: rotate(0deg); + } +} diff --git a/src/frontend/app/page.tsx b/src/frontend/app/page.tsx new file mode 100644 index 0000000..899896d --- /dev/null +++ b/src/frontend/app/page.tsx @@ -0,0 +1,13 @@ +'use client'; + +import {QueryClient, QueryClientProvider} from "react-query"; +import {MainPage} from "@/app/MainPage"; + +export default function Home() { + const queryClient = new QueryClient(); + return ( + + + + ) +} diff --git a/src/frontend/components.json b/src/frontend/components.json new file mode 100644 index 0000000..7a12446 --- /dev/null +++ b/src/frontend/components.json @@ -0,0 +1,21 @@ +{ + "$schema": "https://ui.shadcn.com/schema.json", + "style": "new-york", + "rsc": true, + "tsx": true, + "tailwind": { + "config": "tailwind.config.js", + "css": "app/globals.css", + "baseColor": "neutral", + "cssVariables": true, + "prefix": "" + }, + "aliases": { + "components": "@/components", + "utils": "@/lib/utils", + "ui": "@/components/ui", + "lib": "@/lib", + "hooks": "@/hooks" + }, + "iconLibrary": "lucide" +} \ No newline at end of file diff --git a/src/frontend/components/Loading.tsx b/src/frontend/components/Loading.tsx new file mode 100644 index 0000000..c7f0d22 --- /dev/null +++ b/src/frontend/components/Loading.tsx @@ -0,0 +1,21 @@ +import {EuiLoadingChart, EuiText} from "@elastic/eui"; +import React from "react"; + +export function Loader() { + + // Custom spinner container style + const spinnerContainerStyle: any = { + display: 'flex', + justifyContent: 'center', + alignItems: 'center', + flexDirection: 'column', + height: '200px', + }; + + return ( +
+ + Loading resources +
+ ) +} diff --git a/src/frontend/components/Modal.tsx b/src/frontend/components/Modal.tsx new file mode 100644 index 0000000..7c41b50 --- /dev/null +++ b/src/frontend/components/Modal.tsx @@ -0,0 +1,34 @@ +import React from 'react'; +import {EuiButton, EuiModal, EuiModalBody, EuiModalFooter, EuiModalHeader, EuiModalHeaderTitle,} from '@elastic/eui'; + +// @ts-ignore +export default function ArtefactModal({artefact, onClose}) { + return ( + <> + + + + {artefact.label} + + + +

Backend Type: {artefact.backend_type}

+

Source: {artefact.source}

+

Source Name: {artefact.source_name}

+

Source URL: {artefact.source_url}

+
+ + window.open(artefact.source_url, '_blank', 'noopener,noreferrer')}> + Go to {artefact.source_name} + + + + Close + + +
+ + ); +}; diff --git a/src/frontend/components/MultipleSelector.tsx b/src/frontend/components/MultipleSelector.tsx new file mode 100644 index 0000000..31edc41 --- /dev/null +++ b/src/frontend/components/MultipleSelector.tsx @@ -0,0 +1,608 @@ +import {Command as CommandPrimitive, useCommandState} from 'cmdk'; +import {X} from 'lucide-react'; +import * as React from 'react'; +import {forwardRef, useEffect} from 'react'; + +import {Badge} from '@/components/ui/badge'; +import {Command, CommandGroup, CommandItem, CommandList} from '@/components/ui/command'; +import {cn} from '@/lib/utils'; + +export interface SelectorOption { + value: string; + label: string; + disable?: boolean; + /** fixed option that can't be removed. */ + fixed?: boolean; + + /** Group the options by providing key. */ + [key: string]: string | boolean | undefined; +} + +interface GroupOption { + [key: string]: SelectorOption[]; +} + +interface MultipleSelectorProps { + value?: SelectorOption[]; + defaultOptions?: SelectorOption[]; + /** manually controlled options */ + options?: SelectorOption[]; + placeholder?: string; + /** Loading component. */ + loadingIndicator?: React.ReactNode; + /** Empty component. */ + emptyIndicator?: React.ReactNode; + /** Debounce time for async search. Only work with `onSearch`. */ + delay?: number; + /** + * Only work with `onSearch` prop. Trigger search when `onFocus`. + * For example, when user click on the input, it will trigger the search to get initial options. + **/ + triggerSearchOnFocus?: boolean; + /** async search */ + onSearch?: (value: string) => Promise; + /** + * sync search. This search will not showing loadingIndicator. + * The rest props are the same as async search. + * i.e.: creatable, groupBy, delay. + **/ + onSearchSync?: (value: string) => SelectorOption[]; + onChange?: (options: SelectorOption[]) => void; + /** Limit the maximum number of selected options. */ + maxSelected?: number; + /** When the number of selected options exceeds the limit, the onMaxSelected will be called. */ + onMaxSelected?: (maxLimit: number) => void; + /** Hide the placeholder when there are options selected. */ + hidePlaceholderWhenSelected?: boolean; + disabled?: boolean; + /** Group the options base on provided key. */ + groupBy?: string; + className?: string; + badgeClassName?: string; + /** + * First item selected is a default behavior by cmdk. That is why the default is true. + * This is a workaround solution by add a dummy item. + * + * @reference: https://github.com/pacocoursey/cmdk/issues/171 + */ + selectFirstItem?: boolean; + /** Allow user to create option when there is no option matched. */ + creatable?: boolean; + /** Props of `Command` */ + commandProps?: React.ComponentPropsWithoutRef; + /** Props of `CommandInput` */ + inputProps?: Omit< + React.ComponentPropsWithoutRef, + 'value' | 'placeholder' | 'disabled' + >; + /** hide the clear all button. */ + hideClearAllButton?: boolean; +} + +export interface MultipleSelectorRef { + selectedValue: SelectorOption[]; + input: HTMLInputElement; + focus: () => void; + reset: () => void; +} + +export function useDebounce(value: T, delay?: number): T { + const [debouncedValue, setDebouncedValue] = React.useState(value); + + useEffect(() => { + const timer = setTimeout(() => setDebouncedValue(value), delay || 500); + + return () => { + clearTimeout(timer); + }; + }, [value, delay]); + + return debouncedValue; +} + +function transToGroupOption(options: SelectorOption[], groupBy?: string) { + if (options.length === 0) { + return {}; + } + if (!groupBy) { + return { + '': options, + }; + } + + const groupOption: GroupOption = {}; + options.forEach((option) => { + const key = (option[groupBy] as string) || ''; + if (!groupOption[key]) { + groupOption[key] = []; + } + groupOption[key].push(option); + }); + return groupOption; +} + +function removePickedOption(groupOption: GroupOption, picked: SelectorOption[]) { + const cloneOption = JSON.parse(JSON.stringify(groupOption)) as GroupOption; + + for (const [key, value] of Object.entries(cloneOption)) { + cloneOption[key] = value.filter((val) => !picked.find((p) => p.value === val.value)); + } + return cloneOption; +} + +function isOptionsExist(groupOption: GroupOption, targetOption: SelectorOption[]) { + for (const [, value] of Object.entries(groupOption)) { + if (value.some((option) => targetOption.find((p) => p.value === option.value))) { + return true; + } + } + return false; +} + +/** + * The `CommandEmpty` of shadcn/ui will cause the cmdk empty not rendering correctly. + * So we create one and copy the `Empty` implementation from `cmdk`. + * + * @reference: https://github.com/hsuanyi-chou/shadcn-ui-expansions/issues/34#issuecomment-1949561607 + **/ +const CommandEmpty = forwardRef< + HTMLDivElement, + React.ComponentProps +>(({className, ...props}, forwardedRef) => { + const render = useCommandState((state) => state.filtered.count === 0); + + if (!render) return null; + + return ( +
+ ); +}); + +CommandEmpty.displayName = 'CommandEmpty'; + +const MultipleSelector = React.forwardRef( + ( + { + value, + onChange, + placeholder, + defaultOptions: arrayDefaultOptions = [], + options: arrayOptions, + delay, + onSearch, + onSearchSync, + loadingIndicator, + emptyIndicator, + maxSelected = Number.MAX_SAFE_INTEGER, + onMaxSelected, + hidePlaceholderWhenSelected, + disabled, + groupBy, + className, + badgeClassName, + selectFirstItem = true, + creatable = false, + triggerSearchOnFocus = false, + commandProps, + inputProps, + hideClearAllButton = false, + }: MultipleSelectorProps, + ref: React.Ref, + ) => { + const inputRef = React.useRef(null); + const [open, setOpen] = React.useState(false); + const [onScrollbar, setOnScrollbar] = React.useState(false); + const [isLoading, setIsLoading] = React.useState(false); + const dropdownRef = React.useRef(null); // Added this + + const [selected, setSelected] = React.useState(value || []); + const [options, setOptions] = React.useState( + transToGroupOption(arrayDefaultOptions, groupBy), + ); + const [inputValue, setInputValue] = React.useState(''); + const debouncedSearchTerm = useDebounce(inputValue, delay || 500); + + React.useImperativeHandle( + ref, + () => ({ + selectedValue: [...selected], + input: inputRef.current as HTMLInputElement, + focus: () => inputRef?.current?.focus(), + reset: () => setSelected([]) + }), + [selected], + ); + + const handleClickOutside = (event: MouseEvent | TouchEvent) => { + if ( + dropdownRef.current && + !dropdownRef.current.contains(event.target as Node) && + inputRef.current && + !inputRef.current.contains(event.target as Node) + ) { + setOpen(false); + inputRef.current.blur(); + } + }; + + const handleUnselect = React.useCallback( + (option: SelectorOption) => { + const newOptions = selected.filter((s) => s.value !== option.value); + setSelected(newOptions); + onChange?.(newOptions); + }, + [onChange, selected], + ); + + const handleKeyDown = React.useCallback( + (e: React.KeyboardEvent) => { + const input = inputRef.current; + if (input) { + if (e.key === 'Delete' || e.key === 'Backspace') { + if (input.value === '' && selected.length > 0) { + const lastSelectOption = selected[selected.length - 1]; + // If last item is fixed, we should not remove it. + if (!lastSelectOption.fixed) { + handleUnselect(selected[selected.length - 1]); + } + } + } + // This is not a default behavior of the field + if (e.key === 'Escape') { + input.blur(); + } + } + }, + [handleUnselect, selected], + ); + + useEffect(() => { + if (open) { + document.addEventListener('mousedown', handleClickOutside); + document.addEventListener('touchend', handleClickOutside); + } else { + document.removeEventListener('mousedown', handleClickOutside); + document.removeEventListener('touchend', handleClickOutside); + } + + return () => { + document.removeEventListener('mousedown', handleClickOutside); + document.removeEventListener('touchend', handleClickOutside); + }; + }, [open]); + + useEffect(() => { + if (value) { + setSelected(value); + } + }, [value]); + + useEffect(() => { + /** If `onSearch` is provided, do not trigger options updated. */ + if (!arrayOptions || onSearch) { + return; + } + const newOption = transToGroupOption(arrayOptions || [], groupBy); + if (JSON.stringify(newOption) !== JSON.stringify(options)) { + setOptions(newOption); + } + }, [arrayDefaultOptions, arrayOptions, groupBy, onSearch, options]); + + useEffect(() => { + /** sync search */ + + const doSearchSync = () => { + const res = onSearchSync?.(debouncedSearchTerm); + setOptions(transToGroupOption(res || [], groupBy)); + }; + + const exec = async () => { + if (!onSearchSync || !open) return; + + if (triggerSearchOnFocus) { + doSearchSync(); + } + + if (debouncedSearchTerm) { + doSearchSync(); + } + }; + + void exec(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [debouncedSearchTerm, groupBy, open, triggerSearchOnFocus]); + + useEffect(() => { + /** async search */ + + const doSearch = async () => { + setIsLoading(true); + const res = await onSearch?.(debouncedSearchTerm); + setOptions(transToGroupOption(res || [], groupBy)); + setIsLoading(false); + }; + + const exec = async () => { + if (!onSearch || !open) return; + + if (triggerSearchOnFocus) { + await doSearch(); + } + + if (debouncedSearchTerm) { + await doSearch(); + } + }; + + void exec(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [debouncedSearchTerm, groupBy, open, triggerSearchOnFocus]); + + const CreatableItem = () => { + if (!creatable) return undefined; + if ( + isOptionsExist(options, [{value: inputValue, label: inputValue}]) || + selected.find((s) => s.value === inputValue) + ) { + return undefined; + } + + const Item = ( + { + e.preventDefault(); + e.stopPropagation(); + }} + onSelect={(value: string) => { + if (selected.length >= maxSelected) { + onMaxSelected?.(selected.length); + return; + } + setInputValue(''); + const newOptions = [...selected, {value, label: value}]; + setSelected(newOptions); + onChange?.(newOptions); + }} + > + {`Create "${inputValue}"`} + + ); + + // For normal creatable + if (!onSearch && inputValue.length > 0) { + return Item; + } + + // For async search creatable. avoid showing creatable item before loading at first. + if (onSearch && debouncedSearchTerm.length > 0 && !isLoading) { + return Item; + } + + return undefined; + }; + + const EmptyItem = React.useCallback(() => { + if (!emptyIndicator) return undefined; + + // For async search that showing emptyIndicator + if (onSearch && !creatable && Object.keys(options).length === 0) { + return ( + + {emptyIndicator} + + ); + } + + return {emptyIndicator}; + }, [creatable, emptyIndicator, onSearch, options]); + + const selectables = React.useMemo( + () => removePickedOption(options, selected), + [options, selected], + ); + + /** Avoid Creatable Selector freezing or lagging when paste a long string. */ + const commandFilter = React.useCallback(() => { + if (commandProps?.filter) { + return commandProps.filter; + } + + if (creatable) { + return (value: string, search: string) => { + return value.toLowerCase().includes(search.toLowerCase()) ? 1 : -1; + }; + } + // Using default filter in `cmdk`. We don't have to provide it. + return undefined; + }, [creatable, commandProps?.filter]); + + return ( + { + handleKeyDown(e); + commandProps?.onKeyDown?.(e); + }} + className={cn('h-auto overflow-visible bg-transparent', commandProps?.className)} + shouldFilter={ + commandProps?.shouldFilter !== undefined ? commandProps.shouldFilter : !onSearch + } // When onSearch is provided, we don't want to filter the options. You can still override it. + filter={commandFilter()} + > +
{ + if (disabled) return; + inputRef?.current?.focus(); + }} + > +
+ {selected.map((option) => { + return ( + + {option.label} + + + ); + })} + {/* Avoid having the "Search" Icon */} + { + setInputValue(value); + inputProps?.onValueChange?.(value); + }} + onBlur={(event) => { + if (!onScrollbar) { + setOpen(false); + } + inputProps?.onBlur?.(event); + }} + onFocus={(event) => { + setOpen(true); + triggerSearchOnFocus && onSearch?.(debouncedSearchTerm); + inputProps?.onFocus?.(event); + }} + placeholder={hidePlaceholderWhenSelected && selected.length !== 0 ? '' : placeholder} + className={cn( + 'flex-1 bg-transparent outline-none placeholder:text-muted-foreground', + { + 'w-full': hidePlaceholderWhenSelected, + 'px-3 py-2': selected.length === 0, + 'ml-1': selected.length !== 0, + }, + inputProps?.className, + )} + /> + +
+
+
+ {open && ( + { + setOnScrollbar(false); + }} + onMouseEnter={() => { + setOnScrollbar(true); + }} + onMouseUp={() => { + inputRef?.current?.focus(); + }} + > + {isLoading ? ( + <>{loadingIndicator} + ) : ( + <> + {EmptyItem()} + {CreatableItem()} + {!selectFirstItem && } + {Object.entries(selectables).map(([key, dropdowns]) => ( + + <> + {dropdowns.map((option) => { + return ( + { + e.preventDefault(); + e.stopPropagation(); + }} + onSelect={() => { + if (selected.length >= maxSelected) { + onMaxSelected?.(selected.length); + return; + } + setInputValue(''); + const newOptions = [...selected, option]; + setSelected(newOptions); + onChange?.(newOptions); + }} + className={cn( + 'cursor-pointer', + option.disable && 'cursor-default text-muted-foreground', + )} + > + {option.label} + + ); + })} + + + ))} + + )} + + )} +
+
+ ); + }, +); + +MultipleSelector.displayName = 'MultipleSelector'; +export default MultipleSelector; diff --git a/src/frontend/components/PaginatedCardList.tsx b/src/frontend/components/PaginatedCardList.tsx new file mode 100644 index 0000000..65a6806 --- /dev/null +++ b/src/frontend/components/PaginatedCardList.tsx @@ -0,0 +1,110 @@ +import React, {useState} from 'react'; +import { + Pagination, + PaginationContent, + PaginationEllipsis, + PaginationItem, + PaginationLink, + PaginationNext, + PaginationPrevious, +} from "@/components/ui/pagination"; + +interface PaginatedCardListProps { + // Component props + CardComponent: React.ComponentType; + items?: T[]; + + // Pagination props + itemsPerPage?: number; + + // Styling props + className?: string; + gridClassName?: string; +} + +export const PaginatedCardList = ({ + CardComponent, + items, + itemsPerPage = 6, + className = "", + gridClassName = "grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4 mb-8" + }: PaginatedCardListProps) => { + + const allItems = items ? items : []; + const [currentPage, setCurrentPage] = useState(1); + const totalPages = Math.ceil(allItems.length / itemsPerPage); + const currentItems = allItems.slice( + (currentPage - 1) * itemsPerPage, + currentPage * itemsPerPage + ); + + const getPageNumbers = () => { + const pages = []; + for (let i = 1; i <= totalPages; i++) { + if ( + i === 1 || + i === totalPages || + (i >= currentPage - 1 && i <= currentPage + 1) + ) { + pages.push(i); + } else if (i === currentPage - 2 || i === currentPage + 2) { + pages.push('...'); + } + } + // @ts-ignore + return [...new Set(pages)]; + }; + + const handlePageChange = (page: any) => { + setCurrentPage(page); + }; + + return ( +
+ + {/* Cards Grid */} +
+ {currentItems.map((item: any, index: number) => ( + + ))} +
+ + {/* Pagination */} + {totalPages > 1 && ( + + + + currentPage > 1 && handlePageChange(currentPage - 1)} + className={currentPage === 1 ? "pointer-events-none opacity-50" : "cursor-pointer"} + /> + + + {getPageNumbers().map((page, index) => ( + + {page === '...' ? ( + + ) : ( + handlePageChange(page)} + isActive={currentPage === page} + className="cursor-pointer" + > + {page} + + )} + + ))} + + + currentPage < totalPages && handlePageChange(currentPage + 1)} + className={currentPage === totalPages ? "pointer-events-none opacity-50" : "cursor-pointer"} + /> + + + + )} +
+ ); +}; diff --git a/src/frontend/components/Selector.tsx b/src/frontend/components/Selector.tsx new file mode 100644 index 0000000..e3227e7 --- /dev/null +++ b/src/frontend/components/Selector.tsx @@ -0,0 +1,38 @@ +import * as React from "react" + +import { + Select, + SelectContent, + SelectGroup, + SelectItem, + SelectLabel, + SelectTrigger, + SelectValue, +} from "@/components/ui/select" + +interface SelectorValue { + label: string, + value: string, +} + +export function Selector({placeholder, label, values}: { + placeholder?: string, + label: string, + values: Array +}) { + return ( + + ) +} diff --git a/src/frontend/components/ui/alert.tsx b/src/frontend/components/ui/alert.tsx new file mode 100644 index 0000000..0ee033d --- /dev/null +++ b/src/frontend/components/ui/alert.tsx @@ -0,0 +1,59 @@ +import * as React from "react" +import {cva, type VariantProps} from "class-variance-authority" + +import {cn} from "@/lib/utils" + +const alertVariants = cva( + "relative w-full rounded-lg border px-4 py-3 text-sm [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-foreground [&>svg~*]:pl-7", + { + variants: { + variant: { + default: "bg-background text-foreground", + destructive: + "border-destructive/50 text-destructive dark:border-destructive [&>svg]:text-destructive", + }, + }, + defaultVariants: { + variant: "default", + }, + } +) + +const Alert = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes & VariantProps +>(({className, variant, ...props}, ref) => ( +
+)) +Alert.displayName = "Alert" + +const AlertTitle = React.forwardRef< + HTMLParagraphElement, + React.HTMLAttributes +>(({className, ...props}, ref) => ( +
+)) +AlertTitle.displayName = "AlertTitle" + +const AlertDescription = React.forwardRef< + HTMLParagraphElement, + React.HTMLAttributes +>(({className, ...props}, ref) => ( +
+)) +AlertDescription.displayName = "AlertDescription" + +export {Alert, AlertTitle, AlertDescription} diff --git a/src/frontend/components/ui/badge.tsx b/src/frontend/components/ui/badge.tsx new file mode 100644 index 0000000..e87d62b --- /dev/null +++ b/src/frontend/components/ui/badge.tsx @@ -0,0 +1,36 @@ +import * as React from "react" +import { cva, type VariantProps } from "class-variance-authority" + +import { cn } from "@/lib/utils" + +const badgeVariants = cva( + "inline-flex items-center rounded-md border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2", + { + variants: { + variant: { + default: + "border-transparent bg-primary text-primary-foreground shadow hover:bg-primary/80", + secondary: + "border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80", + destructive: + "border-transparent bg-destructive text-destructive-foreground shadow hover:bg-destructive/80", + outline: "text-foreground", + }, + }, + defaultVariants: { + variant: "default", + }, + } +) + +export interface BadgeProps + extends React.HTMLAttributes, + VariantProps {} + +function Badge({ className, variant, ...props }: BadgeProps) { + return ( +
+ ) +} + +export { Badge, badgeVariants } diff --git a/src/frontend/components/ui/button.tsx b/src/frontend/components/ui/button.tsx new file mode 100644 index 0000000..65d4fcd --- /dev/null +++ b/src/frontend/components/ui/button.tsx @@ -0,0 +1,57 @@ +import * as React from "react" +import { Slot } from "@radix-ui/react-slot" +import { cva, type VariantProps } from "class-variance-authority" + +import { cn } from "@/lib/utils" + +const buttonVariants = cva( + "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0", + { + variants: { + variant: { + default: + "bg-primary text-primary-foreground shadow hover:bg-primary/90", + destructive: + "bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90", + outline: + "border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground", + secondary: + "bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80", + ghost: "hover:bg-accent hover:text-accent-foreground", + link: "text-primary underline-offset-4 hover:underline", + }, + size: { + default: "h-9 px-4 py-2", + sm: "h-8 rounded-md px-3 text-xs", + lg: "h-10 rounded-md px-8", + icon: "h-9 w-9", + }, + }, + defaultVariants: { + variant: "default", + size: "default", + }, + } +) + +export interface ButtonProps + extends React.ButtonHTMLAttributes, + VariantProps { + asChild?: boolean +} + +const Button = React.forwardRef( + ({ className, variant, size, asChild = false, ...props }, ref) => { + const Comp = asChild ? Slot : "button" + return ( + + ) + } +) +Button.displayName = "Button" + +export { Button, buttonVariants } diff --git a/src/frontend/components/ui/card.tsx b/src/frontend/components/ui/card.tsx new file mode 100644 index 0000000..cabfbfc --- /dev/null +++ b/src/frontend/components/ui/card.tsx @@ -0,0 +1,76 @@ +import * as React from "react" + +import { cn } from "@/lib/utils" + +const Card = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
+)) +Card.displayName = "Card" + +const CardHeader = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
+)) +CardHeader.displayName = "CardHeader" + +const CardTitle = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
+)) +CardTitle.displayName = "CardTitle" + +const CardDescription = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
+)) +CardDescription.displayName = "CardDescription" + +const CardContent = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
+)) +CardContent.displayName = "CardContent" + +const CardFooter = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
+)) +CardFooter.displayName = "CardFooter" + +export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent } diff --git a/src/frontend/components/ui/command.tsx b/src/frontend/components/ui/command.tsx new file mode 100644 index 0000000..2cecd91 --- /dev/null +++ b/src/frontend/components/ui/command.tsx @@ -0,0 +1,153 @@ +"use client" + +import * as React from "react" +import { type DialogProps } from "@radix-ui/react-dialog" +import { Command as CommandPrimitive } from "cmdk" +import { Search } from "lucide-react" + +import { cn } from "@/lib/utils" +import { Dialog, DialogContent } from "@/components/ui/dialog" + +const Command = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +Command.displayName = CommandPrimitive.displayName + +const CommandDialog = ({ children, ...props }: DialogProps) => { + return ( + + + + {children} + + + + ) +} + +const CommandInput = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( +
+ + +
+)) + +CommandInput.displayName = CommandPrimitive.Input.displayName + +const CommandList = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) + +CommandList.displayName = CommandPrimitive.List.displayName + +const CommandEmpty = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>((props, ref) => ( + +)) + +CommandEmpty.displayName = CommandPrimitive.Empty.displayName + +const CommandGroup = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) + +CommandGroup.displayName = CommandPrimitive.Group.displayName + +const CommandSeparator = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +CommandSeparator.displayName = CommandPrimitive.Separator.displayName + +const CommandItem = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) + +CommandItem.displayName = CommandPrimitive.Item.displayName + +const CommandShortcut = ({ + className, + ...props +}: React.HTMLAttributes) => { + return ( + + ) +} +CommandShortcut.displayName = "CommandShortcut" + +export { + Command, + CommandDialog, + CommandInput, + CommandList, + CommandEmpty, + CommandGroup, + CommandItem, + CommandShortcut, + CommandSeparator, +} diff --git a/src/frontend/components/ui/dialog.tsx b/src/frontend/components/ui/dialog.tsx new file mode 100644 index 0000000..1647513 --- /dev/null +++ b/src/frontend/components/ui/dialog.tsx @@ -0,0 +1,122 @@ +"use client" + +import * as React from "react" +import * as DialogPrimitive from "@radix-ui/react-dialog" +import { X } from "lucide-react" + +import { cn } from "@/lib/utils" + +const Dialog = DialogPrimitive.Root + +const DialogTrigger = DialogPrimitive.Trigger + +const DialogPortal = DialogPrimitive.Portal + +const DialogClose = DialogPrimitive.Close + +const DialogOverlay = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +DialogOverlay.displayName = DialogPrimitive.Overlay.displayName + +const DialogContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + + + + {children} + + + Close + + + +)) +DialogContent.displayName = DialogPrimitive.Content.displayName + +const DialogHeader = ({ + className, + ...props +}: React.HTMLAttributes) => ( +
+) +DialogHeader.displayName = "DialogHeader" + +const DialogFooter = ({ + className, + ...props +}: React.HTMLAttributes) => ( +
+) +DialogFooter.displayName = "DialogFooter" + +const DialogTitle = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +DialogTitle.displayName = DialogPrimitive.Title.displayName + +const DialogDescription = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +DialogDescription.displayName = DialogPrimitive.Description.displayName + +export { + Dialog, + DialogPortal, + DialogOverlay, + DialogTrigger, + DialogClose, + DialogContent, + DialogHeader, + DialogFooter, + DialogTitle, + DialogDescription, +} diff --git a/src/frontend/components/ui/input.tsx b/src/frontend/components/ui/input.tsx new file mode 100644 index 0000000..69b64fb --- /dev/null +++ b/src/frontend/components/ui/input.tsx @@ -0,0 +1,22 @@ +import * as React from "react" + +import { cn } from "@/lib/utils" + +const Input = React.forwardRef>( + ({ className, type, ...props }, ref) => { + return ( + + ) + } +) +Input.displayName = "Input" + +export { Input } diff --git a/src/frontend/components/ui/label.tsx b/src/frontend/components/ui/label.tsx new file mode 100644 index 0000000..5341821 --- /dev/null +++ b/src/frontend/components/ui/label.tsx @@ -0,0 +1,26 @@ +"use client" + +import * as React from "react" +import * as LabelPrimitive from "@radix-ui/react-label" +import { cva, type VariantProps } from "class-variance-authority" + +import { cn } from "@/lib/utils" + +const labelVariants = cva( + "text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70" +) + +const Label = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef & + VariantProps +>(({ className, ...props }, ref) => ( + +)) +Label.displayName = LabelPrimitive.Root.displayName + +export { Label } diff --git a/src/frontend/components/ui/pagination.tsx b/src/frontend/components/ui/pagination.tsx new file mode 100644 index 0000000..d331105 --- /dev/null +++ b/src/frontend/components/ui/pagination.tsx @@ -0,0 +1,117 @@ +import * as React from "react" +import { ChevronLeft, ChevronRight, MoreHorizontal } from "lucide-react" + +import { cn } from "@/lib/utils" +import { ButtonProps, buttonVariants } from "@/components/ui/button" + +const Pagination = ({ className, ...props }: React.ComponentProps<"nav">) => ( +