├── .eslintrc.json ├── app ├── favicon.ico ├── login │ └── page.tsx ├── signup │ └── page.tsx ├── not-found.tsx ├── loading.tsx ├── add │ ├── loading.tsx │ └── page.tsx ├── categories │ ├── loading.tsx │ ├── page.tsx │ ├── page.module.css │ └── categoriesPage.tsx ├── part │ └── [partId] │ │ ├── page.module.css │ │ ├── loading.tsx │ │ ├── page.tsx │ │ └── partPage.tsx ├── globals.css ├── api │ ├── parts │ │ ├── autocomplete │ │ │ └── route.ts │ │ ├── delete │ │ │ └── route.ts │ │ ├── update │ │ │ └── route.ts │ │ ├── create │ │ │ └── route.ts │ │ ├── search │ │ │ └── route.ts │ │ └── route.ts │ └── auth │ │ ├── [...nextauth] │ │ └── route.tsx │ │ └── register │ │ └── route.tsx ├── page.tsx ├── layout.tsx └── dashboardPage.tsx ├── components ├── PageLayout │ ├── index.tsx │ └── PageLayout.tsx ├── UserAvatar │ ├── index.tsx │ ├── UserAvatar.module.css │ └── UserAvatar.tsx ├── Auth │ ├── index.tsx │ ├── AuthFromContainer.module.css │ ├── AuthFormContainer.tsx │ ├── LoginPage.tsx │ └── SignupPage.tsx └── SettingsProvider │ ├── index.tsx │ └── SettingsProvider.tsx ├── public ├── icon │ ├── favicon.ico │ ├── PartPilot-Icon.png │ ├── favicon-16x16.png │ ├── favicon-32x32.png │ ├── apple-touch-icon.png │ ├── android-chrome-192x192.png │ └── android-chrome-512x512.png ├── images │ ├── PartPilot-Logo.png │ ├── PartPilot-Logo-Background.png │ ├── image-bg.svg │ ├── start.svg │ ├── image-add.svg │ ├── about.svg │ ├── faq.svg │ └── 404.svg └── fonts │ └── Montserrat-Regular.woff2 ├── next.config.mjs ├── prisma ├── migrations │ ├── 20240327083304_inductance │ │ └── migration.sql │ ├── migration_lock.toml │ ├── 20240222180549_unique_part │ │ └── migration.sql │ ├── 20240224184224_big_int │ │ └── migration.sql │ ├── 20240325070525_decimal │ │ └── migration.sql │ ├── 20240316134531_possible_null │ │ └── migration.sql │ ├── 20240222175843_init │ │ └── migration.sql │ ├── 20240224185022_ │ │ └── migration.sql │ └── 20240331202711_next_auth_models │ │ └── migration.sql ├── seed.mjs └── schema.prisma ├── .dockerignore ├── lib ├── components │ ├── LoadingSkeleton.tsx │ ├── NotFoundPage.module.css │ ├── NavHeader.module.css │ ├── NotFoundPage.tsx │ ├── NavFooter.tsx │ ├── NavFooter.module.css │ ├── unit │ │ └── UnitForm.tsx │ ├── search │ │ └── ValueSearch.tsx │ └── NavHeader.tsx ├── prisma.ts ├── helper │ ├── part_state.ts │ └── lcsc_api.ts └── middleware │ └── handler.ts ├── postcss.config.cjs ├── .gitignore ├── docker-compose.yml ├── docker-compose-release.yml ├── Dockerfile ├── tsconfig.json ├── .env ├── package.json ├── .github └── workflows │ └── deploy-image.yml └── README.md /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "next/core-web-vitals" 3 | } 4 | -------------------------------------------------------------------------------- /app/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/PartPilotLab/PartPilot/HEAD/app/favicon.ico -------------------------------------------------------------------------------- /components/PageLayout/index.tsx: -------------------------------------------------------------------------------- 1 | export {default as PageLayout} from './PageLayout' 2 | -------------------------------------------------------------------------------- /components/UserAvatar/index.tsx: -------------------------------------------------------------------------------- 1 | export {default as UserAvatar} from './UserAvatar' 2 | -------------------------------------------------------------------------------- /public/icon/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/PartPilotLab/PartPilot/HEAD/public/icon/favicon.ico -------------------------------------------------------------------------------- /public/icon/PartPilot-Icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/PartPilotLab/PartPilot/HEAD/public/icon/PartPilot-Icon.png -------------------------------------------------------------------------------- /public/icon/favicon-16x16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/PartPilotLab/PartPilot/HEAD/public/icon/favicon-16x16.png -------------------------------------------------------------------------------- /public/icon/favicon-32x32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/PartPilotLab/PartPilot/HEAD/public/icon/favicon-32x32.png -------------------------------------------------------------------------------- /next.config.mjs: -------------------------------------------------------------------------------- 1 | /** @type {import('next').NextConfig} */ 2 | const nextConfig = {}; 3 | 4 | export default nextConfig; 5 | -------------------------------------------------------------------------------- /public/icon/apple-touch-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/PartPilotLab/PartPilot/HEAD/public/icon/apple-touch-icon.png -------------------------------------------------------------------------------- /public/images/PartPilot-Logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/PartPilotLab/PartPilot/HEAD/public/images/PartPilot-Logo.png -------------------------------------------------------------------------------- /public/fonts/Montserrat-Regular.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/PartPilotLab/PartPilot/HEAD/public/fonts/Montserrat-Regular.woff2 -------------------------------------------------------------------------------- /components/Auth/index.tsx: -------------------------------------------------------------------------------- 1 | export { default as LoginPage } from './LoginPage'; 2 | export { default as SignupPage } from './SignupPage'; -------------------------------------------------------------------------------- /public/icon/android-chrome-192x192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/PartPilotLab/PartPilot/HEAD/public/icon/android-chrome-192x192.png -------------------------------------------------------------------------------- /public/icon/android-chrome-512x512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/PartPilotLab/PartPilot/HEAD/public/icon/android-chrome-512x512.png -------------------------------------------------------------------------------- /prisma/migrations/20240327083304_inductance/migration.sql: -------------------------------------------------------------------------------- 1 | -- AlterTable 2 | ALTER TABLE "Parts" ADD COLUMN "inductance" DOUBLE PRECISION; 3 | -------------------------------------------------------------------------------- /public/images/PartPilot-Logo-Background.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/PartPilotLab/PartPilot/HEAD/public/images/PartPilot-Logo-Background.png -------------------------------------------------------------------------------- /app/login/page.tsx: -------------------------------------------------------------------------------- 1 | import { LoginPage } from "@/components/Auth"; 2 | 3 | export default async function Login() { 4 | return 5 | } 6 | -------------------------------------------------------------------------------- /app/signup/page.tsx: -------------------------------------------------------------------------------- 1 | import { SignupPage } from "@/components/Auth"; 2 | 3 | export default function Signup() { 4 | return 5 | } 6 | -------------------------------------------------------------------------------- /app/not-found.tsx: -------------------------------------------------------------------------------- 1 | import NotFoundPage from "@/lib/components/NotFoundPage"; 2 | 3 | export default function NotFound() { 4 | return (); 5 | } 6 | -------------------------------------------------------------------------------- /app/loading.tsx: -------------------------------------------------------------------------------- 1 | import LoadingSkeleton from "@/lib/components/LoadingSkeleton"; 2 | 3 | export default function Loading() { 4 | return 5 | } -------------------------------------------------------------------------------- /app/add/loading.tsx: -------------------------------------------------------------------------------- 1 | import LoadingSkeleton from "@/lib/components/LoadingSkeleton"; 2 | 3 | export default function Loading() { 4 | return 5 | } -------------------------------------------------------------------------------- /prisma/migrations/migration_lock.toml: -------------------------------------------------------------------------------- 1 | # Please do not edit this file manually 2 | # It should be added in your version-control system (i.e. Git) 3 | provider = "postgresql" -------------------------------------------------------------------------------- /app/categories/loading.tsx: -------------------------------------------------------------------------------- 1 | import LoadingSkeleton from "@/lib/components/LoadingSkeleton"; 2 | 3 | export default function Loading() { 4 | return 5 | } -------------------------------------------------------------------------------- /app/part/[partId]/page.module.css: -------------------------------------------------------------------------------- 1 | .grid { 2 | @media (max-width: $mantine-breakpoint-sm) { 3 | width: calc(100vw - var(--mantine-spacing-lg) * 3); 4 | } 5 | } -------------------------------------------------------------------------------- /app/part/[partId]/loading.tsx: -------------------------------------------------------------------------------- 1 | import LoadingSkeleton from "@/lib/components/LoadingSkeleton"; 2 | 3 | export default function Loading() { 4 | return 5 | } -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | .next 3 | next-env.d.ts 4 | docker-compose.yml 5 | Dockerfile 6 | *.md 7 | .env 8 | LICENSE 9 | .gitignore 10 | Dockerfile 11 | docker-compose.yml -------------------------------------------------------------------------------- /components/SettingsProvider/index.tsx: -------------------------------------------------------------------------------- 1 | export {default as SettingsProvider} from './SettingsProvider' 2 | export {SettingsConsumer} from './SettingsProvider' 3 | export type {Settings} from './SettingsProvider' 4 | -------------------------------------------------------------------------------- /components/UserAvatar/UserAvatar.module.css: -------------------------------------------------------------------------------- 1 | .form { 2 | padding: 15px; 3 | display: flex; 4 | flex-direction: column; 5 | gap: 10px; 6 | } 7 | 8 | .avatar { 9 | cursor: pointer; 10 | } 11 | -------------------------------------------------------------------------------- /app/globals.css: -------------------------------------------------------------------------------- 1 | ::-webkit-scrollbar { 2 | width: 20px; 3 | } 4 | ::-webkit-scrollbar-thumb { 5 | background-color: #d6dee1; 6 | border-radius: 20px; 7 | border: 6px solid transparent; 8 | background-clip: content-box; 9 | } 10 | ::-webkit-scrollbar-thumb:hover { 11 | background-color: #a8bbbf; 12 | } 13 | -------------------------------------------------------------------------------- /prisma/migrations/20240222180549_unique_part/migration.sql: -------------------------------------------------------------------------------- 1 | /* 2 | Warnings: 3 | 4 | - A unique constraint covering the columns `[productCode]` on the table `Parts` will be added. If there are existing duplicate values, this will fail. 5 | 6 | */ 7 | -- CreateIndex 8 | CREATE UNIQUE INDEX "Parts_productCode_key" ON "Parts"("productCode"); 9 | -------------------------------------------------------------------------------- /lib/components/LoadingSkeleton.tsx: -------------------------------------------------------------------------------- 1 | import { Center, Flex, Loader } from "@mantine/core"; 2 | 3 | export default function LoadingSkeleton() { 4 | return ( 5 | 6 |
7 | 8 |
9 |
10 | ); 11 | } 12 | -------------------------------------------------------------------------------- /components/Auth/AuthFromContainer.module.css: -------------------------------------------------------------------------------- 1 | .root { 2 | margin-top: 55px; 3 | display: flex; 4 | justify-content: center; 5 | align-items: center; 6 | height: calc(100vh - 438px); 7 | } 8 | 9 | .form { 10 | padding: 30px; 11 | display: flex; 12 | flex-direction: column; 13 | gap: 15px; 14 | width: 100%; 15 | max-width: 400px; 16 | } -------------------------------------------------------------------------------- /prisma/migrations/20240224184224_big_int/migration.sql: -------------------------------------------------------------------------------- 1 | -- AlterTable 2 | ALTER TABLE "Parts" ALTER COLUMN "voltage" SET DATA TYPE BIGINT, 3 | ALTER COLUMN "capacitance" SET DATA TYPE BIGINT, 4 | ALTER COLUMN "current" SET DATA TYPE BIGINT, 5 | ALTER COLUMN "power" SET DATA TYPE BIGINT, 6 | ALTER COLUMN "resistance" SET DATA TYPE BIGINT, 7 | ALTER COLUMN "frequency" SET DATA TYPE BIGINT; 8 | -------------------------------------------------------------------------------- /postcss.config.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: { 3 | 'postcss-preset-mantine': {}, 4 | 'postcss-simple-vars': { 5 | variables: { 6 | 'mantine-breakpoint-xs': '36em', 7 | 'mantine-breakpoint-sm': '48em', 8 | 'mantine-breakpoint-md': '62em', 9 | 'mantine-breakpoint-lg': '75em', 10 | 'mantine-breakpoint-xl': '88em', 11 | }, 12 | }, 13 | }, 14 | }; -------------------------------------------------------------------------------- /prisma/migrations/20240325070525_decimal/migration.sql: -------------------------------------------------------------------------------- 1 | -- AlterTable 2 | ALTER TABLE "Parts" ALTER COLUMN "voltage" SET DATA TYPE DOUBLE PRECISION, 3 | ALTER COLUMN "capacitance" SET DATA TYPE DOUBLE PRECISION, 4 | ALTER COLUMN "current" SET DATA TYPE DOUBLE PRECISION, 5 | ALTER COLUMN "power" SET DATA TYPE DOUBLE PRECISION, 6 | ALTER COLUMN "resistance" SET DATA TYPE DOUBLE PRECISION, 7 | ALTER COLUMN "frequency" SET DATA TYPE DOUBLE PRECISION; 8 | -------------------------------------------------------------------------------- /app/part/[partId]/page.tsx: -------------------------------------------------------------------------------- 1 | export const dynamic = 'force-dynamic'; 2 | import { PartState } from "@/lib/helper/part_state"; 3 | import PartPage from "./partPage"; 4 | import prisma from "@/lib/prisma"; 5 | 6 | export default async function Part({params}: {params: {partId: string}}) { 7 | const partInfo = await prisma.parts.findUnique({where: {id: parseInt(params.partId)}}) as PartState; 8 | return ; 9 | } 10 | -------------------------------------------------------------------------------- /lib/prisma.ts: -------------------------------------------------------------------------------- 1 | declare global { 2 | var prisma: PrismaClient; // This must be a `var` and not a `let / const` 3 | } 4 | import { PrismaClient } from '@prisma/client'; 5 | 6 | let prisma: PrismaClient; 7 | 8 | if (process.env.NODE_ENV === 'production') { 9 | prisma = new PrismaClient(); 10 | } else { 11 | if (!global.prisma) { 12 | global.prisma = new PrismaClient(); 13 | } 14 | prisma = global.prisma; 15 | } 16 | 17 | export default prisma; -------------------------------------------------------------------------------- /app/categories/page.tsx: -------------------------------------------------------------------------------- 1 | export const dynamic = "force-dynamic"; 2 | import { PartState } from "@/lib/helper/part_state"; 3 | import CategoriesPage from "./categoriesPage"; 4 | import prisma from "@/lib/prisma"; 5 | 6 | export default async function Categories() { 7 | const catalogItems = await prisma.parts.findMany({ 8 | distinct: ['parentCatalogName'], 9 | select: { 10 | parentCatalogName: true, 11 | productImages: true, 12 | }, 13 | }); 14 | 15 | return ; 16 | } 17 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | .yarn/install-state.gz 8 | 9 | # testing 10 | /coverage 11 | 12 | # next.js 13 | /.next/ 14 | /out/ 15 | 16 | # production 17 | /build 18 | 19 | # misc 20 | .DS_Store 21 | *.pem 22 | 23 | # debug 24 | npm-debug.log* 25 | yarn-debug.log* 26 | yarn-error.log* 27 | 28 | # local env files 29 | .env*.local 30 | 31 | # vercel 32 | .vercel 33 | 34 | # typescript 35 | *.tsbuildinfo 36 | next-env.d.ts 37 | 38 | # others 39 | .idea 40 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: "3.0" 2 | 3 | services: 4 | postgres: 5 | image: postgres 6 | ports: 7 | - 5432:5432 8 | environment: 9 | - POSTGRES_USER=partpilot 10 | - POSTGRES_PASSWORD=partpilotPass 11 | - POSTGRES_DB=partpilotdb 12 | partpilot: 13 | build: . # for local 14 | depends_on: 15 | - postgres 16 | ports: 17 | - 3000:3000 18 | environment: 19 | - DATABASE_URL=postgresql://partpilot:partpilotPass@postgres:5432/partpilotdb?schema=public 20 | - NEXTAUTH_SECRET=secret 21 | - NEXTAUTH_URL=http://localhost:3000 22 | -------------------------------------------------------------------------------- /prisma/migrations/20240316134531_possible_null/migration.sql: -------------------------------------------------------------------------------- 1 | -- AlterTable 2 | ALTER TABLE "Parts" ALTER COLUMN "title" DROP NOT NULL, 3 | ALTER COLUMN "quantity" SET DEFAULT 0, 4 | ALTER COLUMN "productId" DROP NOT NULL, 5 | ALTER COLUMN "productModel" DROP NOT NULL, 6 | ALTER COLUMN "productDescription" DROP NOT NULL, 7 | ALTER COLUMN "parentCatalogName" DROP NOT NULL, 8 | ALTER COLUMN "catalogName" DROP NOT NULL, 9 | ALTER COLUMN "brandName" DROP NOT NULL, 10 | ALTER COLUMN "encapStandard" DROP NOT NULL, 11 | ALTER COLUMN "pdfLink" DROP NOT NULL, 12 | ALTER COLUMN "productLink" DROP NOT NULL, 13 | ALTER COLUMN "prices" DROP NOT NULL; 14 | -------------------------------------------------------------------------------- /components/Auth/AuthFormContainer.tsx: -------------------------------------------------------------------------------- 1 | import { Stack } from "@mantine/core" 2 | import { FormEvent, ReactNode } from "react" 3 | import classes from './AuthFromContainer.module.css' 4 | 5 | type Props = { 6 | children: ReactNode, 7 | handleSubmit: (e: FormEvent) => void 8 | } 9 | 10 | export default function AuthFormContainer({ children, handleSubmit }: Props) { 11 | return ( 12 | 13 |
17 | {children} 18 |
19 |
20 | ) 21 | } -------------------------------------------------------------------------------- /lib/components/NotFoundPage.module.css: -------------------------------------------------------------------------------- 1 | .root { 2 | padding-top: rem(80px); 3 | padding-bottom: rem(80px); 4 | } 5 | 6 | .title { 7 | font-weight: 900; 8 | font-size: rem(34px); 9 | margin-bottom: var(--mantine-spacing-md); 10 | font-family: Greycliff CF, var(--mantine-font-family); 11 | 12 | @media (max-width: $mantine-breakpoint-sm) { 13 | font-size: rem(32px); 14 | } 15 | } 16 | 17 | .control { 18 | @media (max-width: $mantine-breakpoint-sm) { 19 | width: 100%; 20 | } 21 | } 22 | 23 | .mobileImage { 24 | @media (min-width: 48em) { 25 | display: none; 26 | } 27 | } 28 | 29 | .desktopImage { 30 | @media (max-width: 47.99em) { 31 | display: none; 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /docker-compose-release.yml: -------------------------------------------------------------------------------- 1 | version: "3.0" 2 | 3 | services: 4 | postgres: 5 | image: postgres 6 | ports: 7 | - 5432:5432 8 | environment: 9 | - POSTGRES_USER=partpilot 10 | - POSTGRES_PASSWORD=partpilotPass 11 | - POSTGRES_DB=partpilotdb 12 | volumes: 13 | - pgdata:/var/lib/postgresql/data #replace to bind mount if needed 14 | partpilot: 15 | image: ghcr.io/partpilotlab/partpilot:latest 16 | depends_on: 17 | - postgres 18 | ports: 19 | - 3000:3000 #replace host port(left side) as needed 20 | environment: 21 | - DATABASE_URL=postgresql://partpilot:partpilotPass@postgres:5432/partpilotdb?schema=public 22 | - NEXTAUTH_SECRET=secret 23 | - NEXTAUTH_URL=http://localhost:3000 24 | 25 | volumes: #remove when using bind mount 26 | pgdata: -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:21-bookworm as Builder 2 | 3 | WORKDIR /app 4 | 5 | COPY package*.json ./ 6 | COPY prisma ./prisma/ 7 | 8 | RUN npm ci 9 | 10 | COPY . . 11 | 12 | RUN npx prisma generate && npm run build 13 | 14 | 15 | FROM node:21-bookworm 16 | LABEL org.opencontainers.image.source=https://github.com/PartPilotLab/PartPilot 17 | LABEL org.opencontainers.image.description="Electronic Part Catalog" 18 | LABEL org.opencontainers.image.licenses=AGPL-3.0 19 | 20 | WORKDIR /app 21 | 22 | COPY --from=Builder /app/public ./public 23 | COPY --from=Builder /app/.next ./.next 24 | COPY --from=builder /app/node_modules ./node_modules 25 | COPY --from=builder /app/package*.json ./ 26 | COPY --from=Builder /app/prisma ./prisma 27 | 28 | 29 | EXPOSE 3000 30 | 31 | ENV PORT 3000 32 | 33 | CMD ["npm", "run", "start:migrate"] -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "lib": [ 4 | "dom", 5 | "dom.iterable", 6 | "esnext" 7 | ], 8 | "allowJs": true, 9 | "skipLibCheck": true, 10 | // "strict": true, 11 | "noImplicitAny": false, 12 | "noEmit": true, 13 | "esModuleInterop": true, 14 | "module": "esnext", 15 | "moduleResolution": "bundler", 16 | "resolveJsonModule": true, 17 | "isolatedModules": true, 18 | "jsx": "preserve", 19 | "incremental": true, 20 | "plugins": [ 21 | { 22 | "name": "next" 23 | } 24 | ], 25 | "paths": { 26 | "@/*": [ 27 | "./*" 28 | ] 29 | }, 30 | "strict": false 31 | }, 32 | "include": [ 33 | "next-env.d.ts", 34 | "**/*.ts", 35 | "**/*.tsx", 36 | ".next/types/**/*.ts" 37 | ], 38 | "exclude": [ 39 | "node_modules" 40 | ] 41 | } -------------------------------------------------------------------------------- /app/categories/page.module.css: -------------------------------------------------------------------------------- 1 | .title { 2 | font-size: rem(34px); 3 | font-weight: 900; 4 | 5 | @media (max-width: $mantine-breakpoint-sm) { 6 | font-size: rem(24px); 7 | } 8 | } 9 | 10 | .description { 11 | max-width: rem(600px); 12 | margin: auto; 13 | 14 | &::after { 15 | content: ""; 16 | display: block; 17 | background-color: var(--mantine-color-cyan-filled); 18 | width: rem(45px); 19 | height: rem(2px); 20 | margin-top: var(--mantine-spacing-sm); 21 | margin-left: auto; 22 | margin-right: auto; 23 | } 24 | } 25 | 26 | .card { 27 | border: rem(1px) solid 28 | light-dark(var(--mantine-color-gray-1), var(--mantine-color-dark-5)); 29 | cursor: pointer; 30 | } 31 | 32 | .cardTitle { 33 | &::after { 34 | content: ""; 35 | display: block; 36 | background-color: var(--mantine-color-cyan-filled); 37 | width: rem(45px); 38 | height: rem(2px); 39 | margin-top: var(--mantine-spacing-sm); 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /prisma/migrations/20240222175843_init/migration.sql: -------------------------------------------------------------------------------- 1 | -- CreateTable 2 | CREATE TABLE "Parts" ( 3 | "id" SERIAL NOT NULL, 4 | "title" TEXT NOT NULL, 5 | "quantity" INTEGER NOT NULL, 6 | "productId" INTEGER NOT NULL, 7 | "productCode" TEXT NOT NULL, 8 | "productModel" TEXT NOT NULL, 9 | "productDescription" TEXT NOT NULL, 10 | "parentCatalogName" TEXT NOT NULL, 11 | "catalogName" TEXT NOT NULL, 12 | "brandName" TEXT NOT NULL, 13 | "encapStandard" TEXT NOT NULL, 14 | "productImages" TEXT[], 15 | "pdfLink" TEXT NOT NULL, 16 | "productLink" TEXT NOT NULL, 17 | "prices" JSONB NOT NULL, 18 | "voltage" INTEGER, 19 | "capacitance" INTEGER, 20 | "current" INTEGER, 21 | "power" INTEGER, 22 | "resistance" INTEGER, 23 | "frequency" INTEGER, 24 | "tolerance" TEXT, 25 | "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, 26 | "updatedAt" TIMESTAMP(3) NOT NULL, 27 | 28 | CONSTRAINT "Parts_pkey" PRIMARY KEY ("id") 29 | ); 30 | -------------------------------------------------------------------------------- /app/api/parts/autocomplete/route.ts: -------------------------------------------------------------------------------- 1 | import { extractPartInfoFromLCSCResponse } from "@/lib/helper/lcsc_api"; 2 | import { NextRequest, NextResponse } from "next/server"; 3 | 4 | export async function POST(request: NextRequest) { 5 | try { 6 | const res = await request.json(); 7 | console.log(res); 8 | 9 | const pcNumber = res.productCode; 10 | console.log(pcNumber); 11 | 12 | const LSCSPart = await fetch( 13 | "https://wmsc.lcsc.com/ftps/wm/product/detail?productCode=" + pcNumber 14 | ) 15 | .then((response) => { 16 | return response.json(); 17 | }) 18 | .catch((e: ErrorCallback | any) => { 19 | console.error(e.message); 20 | }); 21 | console.log(LSCSPart); 22 | const partInfo = extractPartInfoFromLCSCResponse(LSCSPart); 23 | return NextResponse.json({ status: 200, body: partInfo }); 24 | } catch (error: ErrorCallback | any) { 25 | console.log(error); 26 | return NextResponse.json({ status: 500, error: error }); 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /app/api/auth/[...nextauth]/route.tsx: -------------------------------------------------------------------------------- 1 | import NextAuth from "next-auth"; 2 | import CredentialsProvider from "next-auth/providers/credentials"; 3 | import { compare } from "bcrypt"; 4 | 5 | const handler = NextAuth({ 6 | providers: [ 7 | CredentialsProvider({ 8 | name: "Credentials", 9 | credentials: { 10 | password: {}, 11 | email: {}, 12 | }, 13 | async authorize(credentials, req) { 14 | const user = await prisma.user.findUnique({ 15 | where: { email: credentials.email }, 16 | }); 17 | if (!user) return null; 18 | const passwordCorrect = await compare( 19 | credentials.password, 20 | user.password 21 | ); 22 | if (passwordCorrect) 23 | return { 24 | id: user.id, 25 | name: user.name, 26 | email: user.email, 27 | image: user.image, 28 | }; 29 | return null; 30 | }, 31 | }), 32 | ], 33 | session: { strategy: "jwt" }, 34 | }); 35 | 36 | export { handler as GET, handler as POST }; 37 | -------------------------------------------------------------------------------- /lib/components/NavHeader.module.css: -------------------------------------------------------------------------------- 1 | .header { 2 | height: rem(56px); 3 | margin-bottom: rem(120px); 4 | background-color: var(--mantine-color-body); 5 | 6 | border-bottom: rem(1px) solid 7 | light-dark(var(--mantine-color-gray-3), var(--mantine-color-dark-4)); 8 | } 9 | 10 | .inner { 11 | height: rem(56px); 12 | display: flex; 13 | justify-content: space-between; 14 | align-items: center; 15 | } 16 | 17 | .link { 18 | display: block; 19 | line-height: 1; 20 | padding: rem(8px) rem(12px); 21 | border-radius: var(--mantine-radius-sm); 22 | text-decoration: none; 23 | color: light-dark(var(--mantine-color-gray-7), var(--mantine-color-dark-0)); 24 | font-size: var(--mantine-font-size-sm); 25 | font-weight: 500; 26 | 27 | @mixin hover { 28 | background-color: light-dark( 29 | var(--mantine-color-gray-0), 30 | var(--mantine-color-dark-6) 31 | ); 32 | } 33 | } 34 | 35 | .linkLabel { 36 | margin-right: rem(5px); 37 | } 38 | 39 | .avatar { 40 | position: absolute !important; 41 | top: 0; 42 | right: 0; 43 | margin: 12px; 44 | } 45 | 46 | -------------------------------------------------------------------------------- /app/api/auth/register/route.tsx: -------------------------------------------------------------------------------- 1 | import {NextRequest, NextResponse} from "next/server"; 2 | import {hash} from 'bcrypt' 3 | import {z} from "zod" 4 | 5 | const UserSchema = z.object({ 6 | name: z.string().optional(), 7 | email: z.string().email(), 8 | password: z.string() 9 | }) 10 | 11 | export async function POST(req: NextRequest) { 12 | try { 13 | const parsedBody = await req.json() 14 | 15 | const validationResult = UserSchema.safeParse(parsedBody) 16 | if (!validationResult.success) return NextResponse 17 | .json({error: "Invalid user creation payload"}, {status: 400}) 18 | 19 | const hashedPassword = await hash(validationResult.data.password, 10) 20 | 21 | const user = await prisma.user.create({ 22 | data: { 23 | ...validationResult.data, 24 | password: hashedPassword 25 | } 26 | }) 27 | 28 | return NextResponse.json(user) 29 | } catch (e) { 30 | if(e.code === "P2002") { 31 | // Unique constraint error --> Email already exists 32 | return NextResponse.json({error: "Email already registered"}, {status: 400}) 33 | } 34 | return NextResponse.json({error: "Error while creating a new user"}, {status: 400}) 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /.env: -------------------------------------------------------------------------------- 1 | # Environment variables declared in this file are automatically made available to Prisma. 2 | # See the documentation for more detail: https://pris.ly/d/prisma-schema#accessing-environment-variables-from-the-schema 3 | 4 | # Prisma supports the native connection string format for PostgreSQL, MySQL, SQLite, SQL Server, MongoDB and CockroachDB. 5 | # See the documentation for all the connection string options: https://pris.ly/d/connection-strings 6 | 7 | # DATABASE_URL="postgresql://partpilot:partpilotPass@postgres:5432/partpilotdb?schema=public" #for docker 8 | DATABASE_URL="postgresql://partpilot:partpilotPass@localhost:5432/partpilotdb?schema=public" #for local 9 | 10 | # Good guide for db: https://stackoverflow.com/questions/30641512/create-database-from-command-line-in-postgresql 11 | # For roles: https://stackoverflow.com/questions/43734650/createdb-database-creation-failed-error-permission-denied-to-create-database 12 | # For windows: https://www.cherryservers.com/blog/postgresql-create-user 13 | 14 | # openssl rand -base64 32 15 | NEXTAUTH_SECRET="K3l7o+g1mCZUQjszYk6SH0k66mmYeX1gh1ANqJA6/9o=" # for local 16 | NEXTAUTH_URL="http://localhost:3000" # for local 17 | -------------------------------------------------------------------------------- /app/api/parts/delete/route.ts: -------------------------------------------------------------------------------- 1 | import { NextRequest, NextResponse } from "next/server"; 2 | import prisma from "@/lib/prisma"; 3 | 4 | export async function POST(request: NextRequest) { 5 | try { 6 | const res = await request.json(); 7 | 8 | const partId = res.id; 9 | // const 10 | const deletedPart = await prisma.parts.delete({ where: { id: partId } }); 11 | const itemCount = await prisma.parts.aggregate({ _count: true }); 12 | const parentCatalogNamesRaw = await prisma.parts.groupBy({ 13 | by: ["parentCatalogName"], 14 | }); 15 | const parentCatalogNames = parentCatalogNamesRaw.map( 16 | (item: any) => item.parentCatalogName 17 | ); 18 | if (deletedPart) { 19 | return NextResponse.json({ 20 | status: 200, 21 | body: deletedPart, 22 | itemCount: itemCount._count, 23 | parentCatalogNames: parentCatalogNames, 24 | message: "Part deleted", 25 | }); 26 | } else { 27 | return NextResponse.json({ status: 500, error: "Part not deleted" }); 28 | } 29 | } catch (error: ErrorCallback | any) { 30 | return NextResponse.json({ status: 500, error: error }); 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /prisma/seed.mjs: -------------------------------------------------------------------------------- 1 | import {PrismaClient} from "@prisma/client"; 2 | 3 | const prisma = new PrismaClient() 4 | 5 | async function main() { 6 | await prisma.parts.upsert({ 7 | where: {productCode: 'C1591'}, 8 | update: {}, 9 | create: { 10 | productCode: 'C1591', 11 | productModel: 'CL10B104KB8NNNC', 12 | quantity: 2, 13 | capacitance: 1, 14 | prices: [] 15 | }, 16 | }) 17 | await prisma.parts.upsert({ 18 | where: {productCode: 'C154120'}, 19 | update: {}, 20 | create: { 21 | productCode: 'C154120', 22 | productModel: 'SDFL2012T150KTF', 23 | quantity: 20, 24 | capacitance: 1, 25 | prices: [] 26 | }, 27 | }) 28 | await prisma.parts.upsert({ 29 | where: {productCode: 'C29538'}, 30 | update: {}, 31 | create: { 32 | productCode: 'C29538', 33 | productModel: 'X322530MSB4SI', 34 | quantity: 5, 35 | capacitance: 1, 36 | prices: [] 37 | }, 38 | }) 39 | } 40 | 41 | main().then(async () => { 42 | await prisma.$disconnect() 43 | }).catch(async (e) => { 44 | console.error(e) 45 | await prisma.$disconnect() 46 | process.exit(1) 47 | }) 48 | -------------------------------------------------------------------------------- /app/page.tsx: -------------------------------------------------------------------------------- 1 | export const dynamic = "force-dynamic"; 2 | import { PartState } from "@/lib/helper/part_state"; 3 | import DashboardPage from "./dashboardPage"; 4 | import prisma from "@/lib/prisma"; 5 | 6 | type Props = { 7 | searchParams?: { 8 | catalog?: string; 9 | }; 10 | }; 11 | 12 | export default async function Home(props: Props) { 13 | //Get catalog search parameter from URL 14 | const catalog = decodeURIComponent(props.searchParams.catalog); 15 | //Get first 10 items 16 | const parts = (await prisma.parts.findMany({ 17 | orderBy: { id: "desc" }, 18 | take: 10, 19 | where: { parentCatalogName: { equals: catalog } }, 20 | })) as PartState[]; 21 | //Get total part count and parent catalog names 22 | const aggregatedParts = await prisma.parts.aggregate({ _count: true }); 23 | const parentCatalogNamesRaw = await prisma.parts.groupBy({ 24 | by: ["parentCatalogName"], 25 | }); 26 | //Filter parent catalog names (exclude null values) 27 | const parentCatalogNames = parentCatalogNamesRaw 28 | .filter((item) => item.parentCatalogName !== null) 29 | .map((item) => item.parentCatalogName); 30 | 31 | return ( 32 | 38 | ); 39 | } 40 | -------------------------------------------------------------------------------- /lib/components/NotFoundPage.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | Image, 3 | Container, 4 | Title, 5 | Text, 6 | Button, 7 | SimpleGrid, 8 | } from "@mantine/core"; 9 | import classes from "./NotFoundPage.module.css"; 10 | 11 | export default function NotFoundPage() { 12 | return ( 13 | 14 | 15 | 404 Image 20 |
21 | Something is not right... 22 | 23 | Page you are trying to open does not exist. You may have mistyped 24 | the address, or the page has been moved to another URL. If you think 25 | this is an error contact support. 26 | 27 | 35 |
36 | 404 Image 41 |
42 |
43 | ); 44 | } 45 | -------------------------------------------------------------------------------- /prisma/migrations/20240224185022_/migration.sql: -------------------------------------------------------------------------------- 1 | /* 2 | Warnings: 3 | 4 | - You are about to alter the column `voltage` on the `Parts` table. The data in that column could be lost. The data in that column will be cast from `BigInt` to `Integer`. 5 | - You are about to alter the column `capacitance` on the `Parts` table. The data in that column could be lost. The data in that column will be cast from `BigInt` to `Integer`. 6 | - You are about to alter the column `current` on the `Parts` table. The data in that column could be lost. The data in that column will be cast from `BigInt` to `Integer`. 7 | - You are about to alter the column `power` on the `Parts` table. The data in that column could be lost. The data in that column will be cast from `BigInt` to `Integer`. 8 | - You are about to alter the column `resistance` on the `Parts` table. The data in that column could be lost. The data in that column will be cast from `BigInt` to `Integer`. 9 | - You are about to alter the column `frequency` on the `Parts` table. The data in that column could be lost. The data in that column will be cast from `BigInt` to `Integer`. 10 | 11 | */ 12 | -- AlterTable 13 | ALTER TABLE "Parts" ALTER COLUMN "voltage" SET DATA TYPE INTEGER, 14 | ALTER COLUMN "capacitance" SET DATA TYPE INTEGER, 15 | ALTER COLUMN "current" SET DATA TYPE INTEGER, 16 | ALTER COLUMN "power" SET DATA TYPE INTEGER, 17 | ALTER COLUMN "resistance" SET DATA TYPE INTEGER, 18 | ALTER COLUMN "frequency" SET DATA TYPE INTEGER; 19 | -------------------------------------------------------------------------------- /app/api/parts/update/route.ts: -------------------------------------------------------------------------------- 1 | import { NextRequest, NextResponse } from "next/server"; 2 | import prisma from "@/lib/prisma"; 3 | 4 | export async function POST(request: NextRequest) { 5 | try { 6 | const res = await request.json(); 7 | const partId = res.id; 8 | const updatedPart = await prisma.parts.update({ 9 | where: { 10 | id: partId, 11 | }, 12 | data: { 13 | title: res.title, 14 | quantity: res.quantity, 15 | productId: res.productId, 16 | productCode: res.productCode, 17 | productModel: res.productModel, 18 | productDescription: res.productDescription, 19 | parentCatalogName: res.parentCatalogName, 20 | catalogName: res.catalogName, 21 | brandName: res.brandName, 22 | encapStandard: res.encapStandard, 23 | productImages: res.productImages, 24 | pdfLink: res.pdfLink, 25 | productLink: res.productLink, 26 | prices: res.prices, 27 | voltage: res.voltage, 28 | resistance: res.resistance, 29 | power: res.power, 30 | current: res.current, 31 | tolerance: res.tolerance, 32 | frequency: res.frequency, 33 | capacitance: res.capacitance, 34 | }, 35 | }); 36 | if(updatedPart){ 37 | return NextResponse.json({ status: 200, body: updatedPart, message: "Part updated"}); 38 | } 39 | else { 40 | return NextResponse.json({ status: 500, error: "Part not updated" }); 41 | } 42 | } catch (error: ErrorCallback | any) { 43 | return NextResponse.json({ status: 500, error: error }); 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /lib/helper/part_state.ts: -------------------------------------------------------------------------------- 1 | export interface PartState { 2 | id: number; 3 | 4 | title?: string; //title 5 | 6 | quantity: number; //quantity 7 | 8 | productId?: number; //productId 9 | productCode: string; //productCode 10 | productModel?: string; //productModel 11 | productDescription?: string; //productIntroEn 12 | 13 | parentCatalogName?: string; //parentCatalogName 14 | catalogName?: string; //catalogName 15 | brandName?: string; //brandNameEn 16 | 17 | encapStandard?: string; //--> called Package 18 | 19 | 20 | productImages?: string[]; //productImages 21 | pdfLink?: string; //pdfUrl 22 | 23 | productLink?: string; //catalogName + _ + title (brackets to -) + _ + productCode 24 | // package: string; 25 | // manufacturer: string; //brandNameEn 26 | 27 | prices?: { ladder: string; price: number }[]; 28 | 29 | 30 | voltage?: number; //param_10953_n --> paramNameEn: "Voltage Rated" 31 | resistance?: number; //param_10835_n --> paramNameEn: "Resistance" 32 | power?: number; //param_10837_n --> paramNameEn: "Power(Watts)" 33 | current?: number; //param_11284_n --> paramNameEn: "Rated Current" 34 | tolerance?: string; //param_10836_s --> paramNameEn: "Tolerance" 35 | frequency?: number; //param_11373_n --> paramNameEn: "Frequency" 36 | // type?: string; 37 | capacitance?: number; //param_10951_n --> paramNameEn: "Capacitance" 38 | inductance?: number; //--> paramNameEn: "Inductance" 39 | // tempretureCoefficient?: number; 40 | // minBuyQuantity?: number; 41 | 42 | createdAt: Date; 43 | updatedAt: Date; 44 | } -------------------------------------------------------------------------------- /components/SettingsProvider/SettingsProvider.tsx: -------------------------------------------------------------------------------- 1 | import {createContext, ReactNode, useCallback, useEffect, useState} from 'react'; 2 | import {useWindowEvent} from "@mantine/hooks"; 3 | 4 | const SettingsContext = createContext(null); 5 | 6 | const settingsKey = 'APP.SETTINGS'; 7 | 8 | export type Settings = { 9 | initialized: boolean, 10 | paletteMode: 'light' | 'dark' 11 | } 12 | 13 | const defaultValues: Settings = { 14 | paletteMode: 'light', 15 | initialized: false 16 | } 17 | 18 | type Props = { 19 | children: ReactNode 20 | } 21 | 22 | export default function SettingsProvider({children}: Props) { 23 | const [settings, setSettings] = useState(defaultValues) 24 | 25 | const updateSettings = useCallback((values: Partial) => { 26 | setSettings(prevState => { 27 | const newState = {...prevState, ...values} 28 | localStorage.setItem(settingsKey, JSON.stringify(newState)) 29 | if (values.paletteMode) document.documentElement.setAttribute("data-mantine-color-scheme", values.paletteMode); 30 | return newState 31 | }); 32 | }, []) 33 | 34 | useEffect(() => { 35 | const storedSettings = localStorage.getItem(settingsKey); 36 | if (storedSettings) updateSettings({...JSON.parse(storedSettings), initialized: true}) 37 | else updateSettings( {initialized: true}) 38 | }, []) 39 | 40 | useWindowEvent('keydown', e => { 41 | if (e.ctrlKey && e.key === 'j') updateSettings({paletteMode: settings.paletteMode === 'dark' ? 'light' : 'dark'}) 42 | }); 43 | 44 | return ( 45 | 46 | {children} 47 | 48 | ) 49 | } 50 | 51 | export const SettingsConsumer = SettingsContext.Consumer; 52 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "partpilot", 3 | "version": "0.1.0", 4 | "private": true, 5 | "scripts": { 6 | "dev": "next dev", 7 | "build": "next build", 8 | "start": "next start", 9 | "lint": "next lint", 10 | "start:migrate": "prisma migrate deploy && npm run start", 11 | "start:dev": "prisma migrate deploy && prisma db seed && npm run dev", 12 | "prisma:seed": "prisma db seed", 13 | "prisma:studio": "prisma studio", 14 | "prisma:generate": "prisma generate" 15 | }, 16 | "prisma": { 17 | "seed": "node prisma/seed.mjs" 18 | }, 19 | "dependencies": { 20 | "@auth/prisma-adapter": "^1.5.2", 21 | "@mantine/carousel": "^7.6.1", 22 | "@mantine/core": "^7.6.1", 23 | "@mantine/form": "^7.6.1", 24 | "@mantine/hooks": "^7.6.1", 25 | "@mantine/notifications": "^7.6.1", 26 | "@tabler/icons-react": "^2.47.0", 27 | "bcrypt": "^5.1.1", 28 | "embla-carousel-react": "^7.1.0", 29 | "framer-motion": "^11.0.5", 30 | "lodash": "^4.17.21", 31 | "mathjs": "^12.4.0", 32 | "next": "14.1.0", 33 | "next-auth": "^4.24.7", 34 | "onscan.js": "^1.5.2", 35 | "react": "^18", 36 | "react-dom": "^18", 37 | "zod": "^3.22.4" 38 | }, 39 | "devDependencies": { 40 | "@prisma/client": "^5.10.2", 41 | "@types/bcrypt": "^5.0.2", 42 | "@types/node": "^20.11.19", 43 | "@types/onscan.js": "^1.5.6", 44 | "@types/react": "^18", 45 | "@types/react-dom": "^18", 46 | "eslint": "^8", 47 | "eslint-config-next": "14.1.0", 48 | "postcss": "^8.4.35", 49 | "postcss-preset-mantine": "^1.13.0", 50 | "postcss-simple-vars": "^7.0.1", 51 | "prisma": "^5.10.2", 52 | "ts-node": "^10.9.2", 53 | "typescript": "^5.3.3" 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /components/PageLayout/PageLayout.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import {ReactNode} from "react"; 4 | import {Notifications} from "@mantine/notifications"; 5 | import NavHeader from "@/lib/components/NavHeader"; 6 | import NavFooter from "@/lib/components/NavFooter"; 7 | import {createTheme, MantineProvider} from "@mantine/core"; 8 | import localFont from "next/font/local"; 9 | import {SettingsProvider, SettingsConsumer, Settings} from "@/components/SettingsProvider"; 10 | import {SessionProvider} from "next-auth/react"; 11 | import {Session} from "next-auth"; 12 | 13 | const myFont = localFont({ 14 | src: "../../public/fonts/Montserrat-Regular.woff2", 15 | }); 16 | 17 | const theme = createTheme({ 18 | fontFamily: myFont.style.fontFamily, 19 | primaryColor: "light", 20 | colors: { 21 | "light": [ 22 | "#e8fbfe", 23 | "#d9f1f6", 24 | "#b3e1ea", 25 | "#89d0df", 26 | "#69c2d5", 27 | "#55bacf", 28 | "#47b5cc", 29 | "#369fb5", 30 | "#278ea2", 31 | "#007b8f" 32 | ] 33 | }, 34 | }); 35 | 36 | type Props = { 37 | children: ReactNode, 38 | session: Session 39 | } 40 | 41 | export default function PageLayout({children, session}: Props) { 42 | return ( 43 | 44 | 45 | 46 | 47 | 48 | {({initialized}: Settings) => { 49 | return initialized && ( 50 |
51 | 54 |
{children}
55 |
56 | 57 |
58 |
59 | ) 60 | }} 61 |
62 |
63 |
64 |
65 | ) 66 | } 67 | -------------------------------------------------------------------------------- /components/Auth/LoginPage.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import { Button, TextInput } from "@mantine/core"; 4 | import { notifications } from "@mantine/notifications"; 5 | import { signIn } from "next-auth/react"; 6 | import { FormEvent } from "react"; 7 | import { useRouter } from "next/navigation"; 8 | import AuthFormContainer from "./AuthFormContainer"; 9 | 10 | export default function LoginPage() { 11 | const router = useRouter(); 12 | 13 | const handleLogin = async (e: FormEvent) => { 14 | e.preventDefault(); 15 | const formData = new FormData(e.currentTarget) 16 | await signIn('credentials', { 17 | email: formData.get("email"), 18 | password: formData.get("password"), 19 | redirect: false 20 | }).then(({ ok }) => { 21 | //Check if the login was successful 22 | if (ok) { 23 | notifications.show({ title: "Success", message: "Logged in successfully!", color: "green" }) 24 | router.push("/"); 25 | } else { 26 | notifications.show({ title: "Error", message: "You have entered an invalid email or password.", color: "red" }) 27 | } 28 | }) 29 | } 30 | 31 | return ( 32 | 33 | 40 | 47 | 48 | 49 | 50 | ) 51 | } 52 | -------------------------------------------------------------------------------- /app/api/parts/create/route.ts: -------------------------------------------------------------------------------- 1 | import { NextRequest, NextResponse } from "next/server"; 2 | import prisma from "@/lib/prisma"; 3 | import { Prisma } from "@prisma/client"; 4 | 5 | //Manually create a part 6 | 7 | export async function POST(request: NextRequest) { 8 | try { 9 | //Request body containing part details 10 | const reqBody = await request.json(); 11 | 12 | console.log(reqBody) 13 | //Check if part already exists 14 | const pcNumber = reqBody.productCode; 15 | const partExists = await prisma.parts.findUnique({ 16 | where: { 17 | productCode: pcNumber, 18 | }, 19 | }); 20 | if (partExists) { 21 | console.log("Part already exists"); 22 | return NextResponse.json({error: "Part already exists" }, {status: 409}); 23 | } else { 24 | //reqBody could contain null values which will raise an error on create 25 | //Thus we get rid of null values 26 | const validData = Object.fromEntries( 27 | Object.entries(reqBody).filter(([key, value]) => value && value !== null) 28 | ) as Prisma.PartsUncheckedCreateInput; 29 | //As PartsUncheckedCreateInput needed, otherwise a TypeError will be raised 30 | console.log("Creating part..."); 31 | const partCreate = await prisma.parts.create({ 32 | data: validData, 33 | }); 34 | 35 | 36 | 37 | if (partCreate) { 38 | console.log("Created part:") 39 | console.log(partCreate) 40 | return NextResponse.json({ 41 | body: partCreate, 42 | message: "Part created", 43 | }, {status: 200}); 44 | } else { 45 | return NextResponse.json({ error: "Part not created" }, {status: 500}); 46 | } 47 | } 48 | } catch (error: ErrorCallback | any) { 49 | console.log("Error creating part", error); 50 | return NextResponse.json({ error: "Error creating part" }, {status: 500}); 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /lib/middleware/handler.ts: -------------------------------------------------------------------------------- 1 | // import type { NextApiRequest, NextApiResponse } from "next"; 2 | // import { getToken } from "next-auth/jwt" 3 | // import jwt from 'jsonwebtoken'; 4 | // import { logger } from "../lib/logger"; 5 | // const secret = process.env.JWT_KEY 6 | 7 | // //API Handler, which checks if the request is authorized 8 | 9 | // //Props: 10 | // //req and res for pulling the token 11 | // //method (e.g: "POST") for defining the handled (allowed) request type 12 | // //func --> executed of authenticated 13 | // export default async function handler( 14 | // req: NextApiRequest, 15 | // res: NextApiResponse, 16 | // method: string, 17 | // func: () => void 18 | // ) { 19 | // //Check if the allowed method is the actual method 20 | // if (req.method === method) { 21 | // //Fetch the token from the request 22 | // const token = await getToken({ req, secret: secret}) 23 | 24 | // //If a token is existing (and not null, which would mean, that the request is unauthenticated) 25 | // if(token) { 26 | // try { 27 | // //Check if the token is expired 28 | // //@ts-ignore 29 | // if(token.exp * 1000 < Date.now()) { 30 | // //EXPIRED 31 | // logger.debug("EXPIRED TOKEN") 32 | // res.status(401).send({message: "Token expired"}) 33 | // } else { 34 | // //Token is not expired and authorized --> execute function 35 | // await func() 36 | // } 37 | 38 | // } catch (e) { 39 | // //Not authenticated return status 40 | // res.status(401) 41 | // } 42 | // } else { 43 | // res.status(401) 44 | // } 45 | // } else { 46 | // //Method not allowed 47 | // throw new Error( 48 | // `The HTTP ${req.method} method is not supported at this route.` 49 | // ); 50 | // } 51 | // res.end() 52 | // } -------------------------------------------------------------------------------- /.github/workflows/deploy-image.yml: -------------------------------------------------------------------------------- 1 | # 2 | name: Create and publish a Docker image for Partpilot 3 | 4 | # Configures this workflow to run every time a change is pushed to the branch called `release`. 5 | on: 6 | release: 7 | types: [published] 8 | 9 | # Defines two custom environment variables for the workflow. These are used for the Container registry domain, and a name for the Docker image that this workflow builds. 10 | env: 11 | REGISTRY: ghcr.io 12 | IMAGE_NAME: ${{ github.repository }} 13 | 14 | 15 | # There is a single job in this workflow. It's configured to run on the latest available version of Ubuntu. 16 | jobs: 17 | build-and-push-image: 18 | runs-on: ubuntu-latest 19 | # Sets the permissions granted to the `GITHUB_TOKEN` for the actions in this job. 20 | permissions: 21 | contents: read 22 | packages: write 23 | # 24 | steps: 25 | - name: Checkout repository 26 | uses: actions/checkout@v4 27 | # Uses the `docker/login-action` action to log in to the Container registry registry using the account and password that will publish the packages. Once published, the packages are scoped to the account defined here. 28 | - name: Log in to the Container registry 29 | uses: docker/login-action@65b78e6e13532edd9afa3aa52ac7964289d1a9c1 30 | with: 31 | registry: ${{ env.REGISTRY }} 32 | username: ${{ github.actor }} 33 | password: ${{ secrets.GITHUB_TOKEN }} 34 | 35 | - name: Extract metadata (tags, labels) for Docker 36 | id: meta 37 | uses: docker/metadata-action@9ec57ed1fcdbf14dcef7dfbe97b2010124a938b7 38 | with: 39 | images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} 40 | 41 | - name: Build and push Docker image 42 | uses: docker/build-push-action@f2a1d5e99d037542a71f64918e516c093c6f3fc4 43 | with: 44 | context: . 45 | push: true 46 | tags: ${{ steps.meta.outputs.tags }} 47 | labels: ${{ steps.meta.outputs.labels }} 48 | -------------------------------------------------------------------------------- /prisma/migrations/20240331202711_next_auth_models/migration.sql: -------------------------------------------------------------------------------- 1 | -- CreateTable 2 | CREATE TABLE "Account" ( 3 | "id" TEXT NOT NULL, 4 | "userId" TEXT NOT NULL, 5 | "type" TEXT NOT NULL, 6 | "provider" TEXT NOT NULL, 7 | "providerAccountId" TEXT NOT NULL, 8 | "refresh_token" TEXT, 9 | "access_token" TEXT, 10 | "expires_at" INTEGER, 11 | "token_type" TEXT, 12 | "scope" TEXT, 13 | "id_token" TEXT, 14 | "session_state" TEXT, 15 | 16 | CONSTRAINT "Account_pkey" PRIMARY KEY ("id") 17 | ); 18 | 19 | -- CreateTable 20 | CREATE TABLE "Session" ( 21 | "id" TEXT NOT NULL, 22 | "sessionToken" TEXT NOT NULL, 23 | "userId" TEXT NOT NULL, 24 | "expires" TIMESTAMP(3) NOT NULL, 25 | 26 | CONSTRAINT "Session_pkey" PRIMARY KEY ("id") 27 | ); 28 | 29 | -- CreateTable 30 | CREATE TABLE "User" ( 31 | "id" TEXT NOT NULL, 32 | "name" TEXT, 33 | "email" TEXT, 34 | "emailVerified" TIMESTAMP(3), 35 | "password" TEXT, 36 | "image" TEXT, 37 | 38 | CONSTRAINT "User_pkey" PRIMARY KEY ("id") 39 | ); 40 | 41 | -- CreateTable 42 | CREATE TABLE "VerificationToken" ( 43 | "identifier" TEXT NOT NULL, 44 | "token" TEXT NOT NULL, 45 | "expires" TIMESTAMP(3) NOT NULL 46 | ); 47 | 48 | -- CreateIndex 49 | CREATE UNIQUE INDEX "Account_provider_providerAccountId_key" ON "Account"("provider", "providerAccountId"); 50 | 51 | -- CreateIndex 52 | CREATE UNIQUE INDEX "Session_sessionToken_key" ON "Session"("sessionToken"); 53 | 54 | -- CreateIndex 55 | CREATE UNIQUE INDEX "User_email_key" ON "User"("email"); 56 | 57 | -- CreateIndex 58 | CREATE UNIQUE INDEX "VerificationToken_token_key" ON "VerificationToken"("token"); 59 | 60 | -- CreateIndex 61 | CREATE UNIQUE INDEX "VerificationToken_identifier_token_key" ON "VerificationToken"("identifier", "token"); 62 | 63 | -- AddForeignKey 64 | ALTER TABLE "Account" ADD CONSTRAINT "Account_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; 65 | 66 | -- AddForeignKey 67 | ALTER TABLE "Session" ADD CONSTRAINT "Session_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; 68 | -------------------------------------------------------------------------------- /lib/components/NavFooter.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | import { Text, Container, ActionIcon, Group, rem, Image, ThemeIcon } from "@mantine/core"; 3 | import { 4 | IconBrandTwitter 5 | } from "@tabler/icons-react"; 6 | import classes from "./NavFooter.module.css"; 7 | import Link from "next/link"; 8 | import { ReactNode } from "react"; 9 | 10 | const data = [ 11 | { 12 | title: "Project", 13 | links: [ 14 | // { label: "About Us", link: "/about", icon: undefined }, 15 | { label: "GitHub", link: "https://github.com/PartPilotLab/PartPilot", icon: undefined }, 16 | ], 17 | }, 18 | { 19 | title: 'Community', 20 | links: [ 21 | // { label: 'Join Discord', link: '#', icon: }, 22 | { label: 'Follow on X', link: '#', icon: }, 23 | ], 24 | }, 25 | ] as {title: string, links: {label: string, link: string, icon: undefined | ReactNode}[]}[]; 26 | 27 | export default function NavFooter() { 28 | const groups = data.map((group) => { 29 | const links = group.links.map((link, index) => ( 30 | 31 | 35 | {link.label} 36 | 37 | 38 | )); 39 | 40 | return ( 41 |
42 | {group.title} 43 | {links} 44 |
45 | ); 46 | }); 47 | 48 | return ( 49 |
50 | 51 |
52 | image 58 | 59 |
60 |
{groups}
61 |
62 | 63 | 64 | © 2024 PartPilot. Built with ❤️ by Lenni and 65 | supporters. 66 | 67 | 68 |
69 | ); 70 | } 71 | -------------------------------------------------------------------------------- /app/layout.tsx: -------------------------------------------------------------------------------- 1 | import type { Metadata } from "next"; 2 | import "@mantine/core/styles.css"; 3 | import "@mantine/carousel/styles.css"; 4 | import "./globals.css"; 5 | import "@mantine/notifications/styles.css"; 6 | import {PageLayout} from "@/components/PageLayout"; 7 | import {getServerSession} from "next-auth"; 8 | 9 | export const metadata: Metadata = { 10 | title: "PartPilot - Electronics Part Management", 11 | description: "Free And Open Source Electronics Part Management For LCSC", 12 | }; 13 | 14 | export default async function RootLayout({ 15 | children, 16 | }: Readonly<{ 17 | children: React.ReactNode; 18 | }>) { 19 | const session = await getServerSession() 20 | 21 | return ( 22 | 23 | 24 | 25 | 26 | 27 | 32 | 38 | 44 | 45 | 46 | 50 | 51 | 55 | 56 | 57 | 61 | 62 | 63 | 64 | 65 | {children} 66 | 67 | 68 | 69 | ); 70 | } 71 | -------------------------------------------------------------------------------- /lib/helper/lcsc_api.ts: -------------------------------------------------------------------------------- 1 | import { abs, bignumber, unit } from "mathjs"; 2 | import { PartState } from "./part_state"; 3 | 4 | export function extractPartInfoFromLCSCResponse(lcsc_response: any): PartState { 5 | const result = lcsc_response.result; 6 | 7 | const productLink = `https://www.lcsc.com/product-detail/${result.catalogName.replace(/\s/g, '-').replace(/-\(/g, '_').replace(/\)-/g, '_').replace(/-{2,}/g, '-').replace(/\//g, '_')}_${result.title.replace(/\s/g, '-').replace(/-\(/g, '_').replace(/\)-/g, '_').replace(/-{2,}/g, '-').replace(/\//g, '_')}_${result.productCode}.html`; 8 | let paramVOList = []; 9 | if(result.paramVOList) { 10 | paramVOList = result.paramVOList.reduce((acc: any, curr: any) => { 11 | let value = curr.paramValueEnForSearch; 12 | if(curr.paramNameEn == "Tolerance"){ 13 | value = curr.paramValueEn 14 | } 15 | acc[curr.paramNameEn] = value; 16 | return acc; 17 | }, {}); 18 | } 19 | console.log(paramVOList); 20 | 21 | return { 22 | id: result.productId.toString(), 23 | title: result.title, 24 | quantity: result.stockNumber, 25 | productId: result.productId, 26 | productCode: result.productCode, 27 | productModel: result.productModel, 28 | productDescription: result.productIntroEn, 29 | parentCatalogName: result.parentCatalogName, 30 | catalogName: result.catalogName, 31 | brandName: result.brandNameEn, 32 | encapStandard: result.encapStandard, 33 | productImages: result.productImages, 34 | pdfLink: result.pdfUrl, 35 | productLink: productLink, 36 | prices: result.productPriceList.map((price: any) => ({ 37 | ladder: price.ladder.toString(), 38 | price: parseFloat(price.productPrice), 39 | })), 40 | voltage: paramVOList["Voltage Rated"] ?? undefined, 41 | resistance: paramVOList["Resistance"] ?? undefined, 42 | power: paramVOList["Power(Watts)"] ?? undefined, 43 | current: paramVOList["Rated Current"] ?? undefined, 44 | tolerance: paramVOList["Tolerance"] ?? undefined, 45 | frequency: paramVOList["Frequency"] ?? undefined, 46 | capacitance: paramVOList["Capacitance"] ?? undefined, //in pF (pico Farad) 47 | inductance: paramVOList["Inductance"] ?? undefined, //in uH (micro Henry) 48 | createdAt: new Date(), 49 | updatedAt: new Date(), 50 | }; 51 | } -------------------------------------------------------------------------------- /lib/components/NavFooter.module.css: -------------------------------------------------------------------------------- 1 | .footer { 2 | /* margin-top: rem(120px); */ 3 | padding-top: calc(var(--mantine-spacing-xl) * 2); 4 | padding-bottom: calc(var(--mantine-spacing-xl) * 2); 5 | background-color: light-dark( 6 | var(--mantine-color-gray-0), 7 | var(--mantine-color-dark-6) 8 | ); 9 | border-top: rem(1px) solid 10 | light-dark(var(--mantine-color-gray-2), var(--mantine-color-dark-5)); 11 | } 12 | 13 | .logo { 14 | max-width: rem(200px); 15 | 16 | @media (max-width: $mantine-breakpoint-sm) { 17 | display: flex; 18 | flex-direction: column; 19 | align-items: center; 20 | } 21 | } 22 | 23 | .description { 24 | margin-top: rem(5px); 25 | 26 | @media (max-width: $mantine-breakpoint-sm) { 27 | margin-top: var(--mantine-spacing-xs); 28 | text-align: center; 29 | } 30 | } 31 | 32 | .inner { 33 | display: flex; 34 | justify-content: space-between; 35 | 36 | @media (max-width: $mantine-breakpoint-sm) { 37 | flex-direction: column; 38 | align-items: center; 39 | } 40 | } 41 | 42 | .groups { 43 | display: flex; 44 | flex-wrap: wrap; 45 | 46 | @media (max-width: $mantine-breakpoint-sm) { 47 | display: none; 48 | } 49 | } 50 | 51 | .wrapper { 52 | width: rem(160px); 53 | } 54 | 55 | .link { 56 | display: block; 57 | color: light-dark(var(--mantine-color-gray-6), var(--mantine-color-dark-1)); 58 | font-size: var(--mantine-font-size-sm); 59 | padding-top: rem(3px); 60 | padding-bottom: rem(3px); 61 | 62 | &:hover { 63 | text-decoration: underline; 64 | } 65 | } 66 | 67 | .title { 68 | font-size: var(--mantine-font-size-lg); 69 | font-weight: 700; 70 | font-family: Greycliff CF, var(--mantine-font-family); 71 | margin-bottom: calc(var(--mantine-spacing-xs) / 2); 72 | color: light-dark(var(--mantine-color-black), var(--mantine-color-white)); 73 | } 74 | 75 | .afterFooter { 76 | display: flex; 77 | justify-content: space-between; 78 | align-items: center; 79 | margin-top: var(--mantine-spacing-xl); 80 | padding-top: var(--mantine-spacing-xl); 81 | padding-bottom: var(--mantine-spacing-xl); 82 | border-top: rem(1px) solid 83 | light-dark(var(--mantine-color-gray-2), var(--mantine-color-dark-4)); 84 | 85 | @media (max-width: $mantine-breakpoint-sm) { 86 | flex-direction: column; 87 | } 88 | } 89 | 90 | .social { 91 | @media (max-width: $mantine-breakpoint-sm) { 92 | margin-top: var(--mantine-spacing-xs); 93 | } 94 | } 95 | -------------------------------------------------------------------------------- /app/categories/categoriesPage.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { 4 | Badge, 5 | Group, 6 | Paper, 7 | SimpleGrid, 8 | Stack, 9 | Title, 10 | Text, 11 | Card, 12 | Image, 13 | Space, 14 | Tooltip, 15 | ActionIcon, 16 | } from "@mantine/core"; 17 | import classes from "./page.module.css"; 18 | import { PartState } from "@/lib/helper/part_state"; 19 | import { useRouter } from "next/navigation"; 20 | import { IconChevronLeft } from "@tabler/icons-react"; 21 | 22 | export default function CategoriesPage({ 23 | catalogItems, 24 | }: { 25 | catalogItems: PartState[]; 26 | }) { 27 | const router = useRouter(); 28 | return ( 29 | 30 | 31 | { 36 | router.push("/"); 37 | }} 38 | > 39 | 40 | 41 | 42 | 43 | 44 | 45 | Categories 46 | 47 | 48 | 49 | Directly Sort by Category 50 | 51 | 52 | 53 | Click on a category to see all the products in that category 54 | 55 | 56 | 57 | {catalogItems.map((item) => ( 58 | { 65 | router.push(`/?catalog=${encodeURIComponent(item.parentCatalogName)}`); 66 | }} 67 | > 68 | 69 | 74 | 75 | 76 | {item.parentCatalogName} 77 | 78 | 79 | ))} 80 | 81 | 82 | 83 | ); 84 | } 85 | -------------------------------------------------------------------------------- /prisma/schema.prisma: -------------------------------------------------------------------------------- 1 | // This is your Prisma schema file, 2 | // learn more about it in the docs: https://pris.ly/d/prisma-schema 3 | 4 | // Looking for ways to speed up your queries, or scale easily with your serverless or edge functions? 5 | // Try Prisma Accelerate: https://pris.ly/cli/accelerate-init 6 | 7 | generator client { 8 | provider = "prisma-client-js" 9 | } 10 | 11 | datasource db { 12 | provider = "postgresql" 13 | url = env("DATABASE_URL") 14 | } 15 | 16 | model Parts { 17 | id Int @id @default(autoincrement()) 18 | title String? 19 | quantity Int @default(0) 20 | 21 | productId Int? 22 | productCode String @unique 23 | productModel String? 24 | productDescription String? 25 | 26 | parentCatalogName String? 27 | catalogName String? 28 | brandName String? 29 | 30 | encapStandard String? 31 | 32 | productImages String[] 33 | pdfLink String? 34 | productLink String? 35 | 36 | prices Json? 37 | 38 | voltage Float? //Float is a "double precision" in Postgres 39 | capacitance Float? 40 | current Float? 41 | power Float? 42 | resistance Float? 43 | frequency Float? 44 | inductance Float? 45 | 46 | tolerance String? 47 | 48 | createdAt DateTime @default(now()) 49 | updatedAt DateTime @updatedAt 50 | } 51 | 52 | model Account { 53 | id String @id @default(cuid()) 54 | userId String 55 | type String 56 | provider String 57 | providerAccountId String 58 | refresh_token String? @db.Text 59 | access_token String? @db.Text 60 | expires_at Int? 61 | token_type String? 62 | scope String? 63 | id_token String? @db.Text 64 | session_state String? 65 | 66 | user User @relation(fields: [userId], references: [id], onDelete: Cascade) 67 | 68 | @@unique([provider, providerAccountId]) 69 | } 70 | 71 | model Session { 72 | id String @id @default(cuid()) 73 | sessionToken String @unique 74 | userId String 75 | expires DateTime 76 | user User @relation(fields: [userId], references: [id], onDelete: Cascade) 77 | } 78 | 79 | model User { 80 | id String @id @default(cuid()) 81 | name String? 82 | email String? @unique 83 | emailVerified DateTime? 84 | password String? 85 | image String? 86 | accounts Account[] 87 | sessions Session[] 88 | } 89 | 90 | model VerificationToken { 91 | identifier String 92 | token String @unique 93 | expires DateTime 94 | 95 | @@unique([identifier, token]) 96 | } 97 | -------------------------------------------------------------------------------- /components/Auth/SignupPage.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import { Button, TextInput } from "@mantine/core"; 4 | import { notifications } from "@mantine/notifications"; 5 | import { signIn } from "next-auth/react"; 6 | import { FormEvent } from "react"; 7 | import { useRouter } from "next/navigation"; 8 | import AuthFormContainer from "./AuthFormContainer"; 9 | 10 | export default function SignupPage() { 11 | const router = useRouter(); 12 | 13 | const handleSignup = async (e: FormEvent) => { 14 | e.preventDefault() 15 | const formData = new FormData(e.currentTarget) 16 | const response = await fetch('/api/auth/register', { 17 | method: "POST", 18 | body: JSON.stringify({ 19 | name: formData.get('name'), 20 | email: formData.get('email'), 21 | password: formData.get('password'), 22 | }) 23 | }).then(res => res.json()) 24 | if (response.error) { 25 | console.error(response.error) 26 | notifications.show({ title: "Error", message: response.error, color: "red" }) 27 | } else { 28 | //Sign user in when registration is successful 29 | await signIn('credentials', { 30 | email: formData.get('email'), 31 | password: formData.get('password'), 32 | redirect: false 33 | }).then(({ ok }) => { 34 | if (ok) { 35 | router.push('/') 36 | notifications.show({ title: "Success", message: "Registered and logged in successfully!", color: "green" }) 37 | } else { 38 | notifications.show({ title: "Error", message: "Something went wrong while logging in. Try to login again.", color: "red" }) 39 | } 40 | }); 41 | } 42 | } 43 | 44 | return ( 45 | 46 | 53 | 60 | 67 | 68 | 69 | ) 70 | } 71 | -------------------------------------------------------------------------------- /app/api/parts/search/route.ts: -------------------------------------------------------------------------------- 1 | import { NextRequest, NextResponse } from "next/server"; 2 | import prisma from "@/lib/prisma"; 3 | import { Prisma } from "@prisma/client"; 4 | 5 | // For advanced search: fetch all distinct values for each column 6 | // Then have a selector allowing to choose from those values 7 | function convertOperation(operation: string) { 8 | switch (operation) { 9 | case ">": 10 | return "gt"; 11 | case ">=": 12 | return "gte"; 13 | case "=": 14 | return "equals"; 15 | case "<": 16 | return "lt"; 17 | case "<=": 18 | return "lte"; 19 | default: 20 | throw new Error(`Invalid operation: ${operation}`); 21 | } 22 | } 23 | 24 | export async function POST(request: NextRequest) { 25 | try { 26 | const res = await request.json(); 27 | const filter = res.filter; 28 | const page = res.page; 29 | 30 | let where: Prisma.PartsWhereInput = {}; 31 | 32 | if (filter.productCode) { 33 | where.productCode = { 34 | contains: filter.productCode, 35 | }; 36 | } 37 | 38 | if (filter.productTitle) { 39 | where.title = { 40 | contains: filter.productTitle, 41 | }; 42 | } 43 | 44 | if (filter.productDescription) { 45 | where.productDescription = { 46 | contains: filter.productDescription, 47 | }; 48 | } 49 | 50 | if (filter.parentCatalogName) { 51 | where.parentCatalogName = { 52 | equals: filter.parentCatalogName, 53 | }; 54 | } 55 | if (filter.encapStandard) { 56 | where.encapStandard = { 57 | contains: filter.encapStandard, 58 | }; 59 | } 60 | 61 | // Add conditions based on the filter object 62 | for (const key in filter) { 63 | if (key === 'productCode' || key === 'productTitle' || key === 'productDescription' || key === 'parentCatalogName' || key === 'encapStandard') { 64 | continue; // Skip these keys, they are already handled above 65 | } 66 | if ( 67 | filter[key] !== null && 68 | filter[key].value !== null && 69 | filter[key].operation !== null 70 | ) { 71 | let operation = convertOperation(filter[key].operation); 72 | let temp: Prisma.PartsWhereInput = {}; 73 | if (filter[key].value) { 74 | (temp as any)[key] = { 75 | [operation]: filter[key].value, 76 | }; 77 | } 78 | where = { ...where, ...temp }; 79 | } 80 | } 81 | 82 | const parts = await prisma.parts.findMany({ 83 | where, 84 | orderBy: { id: "desc" }, 85 | take: 10, 86 | skip: page ? (page - 1) * 10 : 0, 87 | }); 88 | return NextResponse.json({ status: 200, parts: parts }); 89 | } catch (error: ErrorCallback | any) { 90 | console.log(error); 91 | return NextResponse.json({ status: 500, error: error }); 92 | } 93 | } 94 | -------------------------------------------------------------------------------- /public/images/image-bg.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /lib/components/unit/UnitForm.tsx: -------------------------------------------------------------------------------- 1 | import { Paper, Group, NumberInput, Select } from "@mantine/core"; 2 | import { unit } from "mathjs"; 3 | import { useState, forwardRef, useImperativeHandle } from "react"; 4 | 5 | import { create, all } from "mathjs"; 6 | //Todos: pay attention to nF; get initial value from autocomplete (and type in case of capacitance); 7 | 8 | const math = create(all); 9 | interface UnitFormProps { 10 | valueType: ValueType; 11 | } 12 | type UnitPrefixes = 13 | | "p" 14 | | "n" 15 | // | "μ" 16 | | "u" 17 | | "m" 18 | | "" 19 | | "k" 20 | | "M" 21 | | "G" 22 | | "T" 23 | | "P" 24 | | "E" 25 | | "Z" 26 | | "Y"; 27 | type ValueType = 28 | | "voltage" 29 | | "current" 30 | | "resistance" 31 | | "power" 32 | | "frequency" 33 | | "capacitance" 34 | | "inductance"; // Add more value types here 35 | 36 | const prefixes: UnitPrefixes[] = [ 37 | "p", 38 | "n", 39 | // "μ", 40 | "u", 41 | "m", 42 | "", 43 | "k", 44 | "M", 45 | "G", 46 | "T", 47 | "P", 48 | "E", 49 | "Z", 50 | "Y", 51 | ]; 52 | const baseUnits: Record = { 53 | voltage: "V", 54 | current: "A", 55 | resistance: "Ω", 56 | power: "W", 57 | frequency: "Hz", 58 | capacitance: "F", 59 | inductance: "H", 60 | }; 61 | 62 | const units: Record = Object.keys(baseUnits).reduce( 63 | (acc, key) => { 64 | acc[key as ValueType] = prefixes.map( 65 | (prefix) => prefix + baseUnits[key as ValueType] 66 | ); 67 | return acc; 68 | }, 69 | {} as Record 70 | ); 71 | 72 | export interface UnitFormRef { 73 | getSearchParameters: () => { value: number | null }; 74 | setValue: (value: number | null, unit?: string) => void; 75 | clear: () => void; 76 | } 77 | 78 | const UnitForm = forwardRef( 79 | ({ valueType }, ref) => { 80 | const [value, setValue] = useState(null); 81 | const [unit, setUnit] = useState(baseUnits[valueType]); 82 | 83 | useImperativeHandle(ref, () => ({ 84 | getSearchParameters: () => { 85 | let siValue = value; 86 | if (value !== null && unit !== null && valueType !== "capacitance") { 87 | let adjustedUnit = unit; 88 | if (unit === "Ω") { 89 | adjustedUnit = "ohm"; 90 | } 91 | siValue = math.unit(value, adjustedUnit).toSI().value; 92 | } 93 | if (valueType === "capacitance" && value !== null && unit !== null) { 94 | siValue = math.unit(value, unit).toNumber("pF"); 95 | } 96 | if (valueType === "inductance" && value !== null && unit !== null) { 97 | siValue = math.unit(value, unit).toNumber("uH"); 98 | } 99 | return { value: siValue }; 100 | }, 101 | setValue: (value: number | null, unit?: string) => { 102 | if (unit) { 103 | setUnit(unit); 104 | } 105 | setValue(value); 106 | }, 107 | clear: () => { 108 | setValue(null); 109 | setUnit(baseUnits[valueType]); 110 | }, 111 | })); 112 | 113 | return ( 114 | 115 | 116 | setValue(Number(value))} 120 | w={"75%"} 121 | size="sm" 122 | radius={0} 123 | /> 124 | } 120 | rightSectionWidth={0} 121 | rightSectionPointerEvents="none" 122 | /> 123 | setValue(Number(value))} 127 | w={"55%"} 128 | size="sm" 129 | radius={0} 130 | /> 131 | 499 | 506 | 514 | 515 | 516 | 517 | 518 | 519 | 520 | 521 | {parts != null && parts.length > 0 ? ( 522 | 523 | 524 | 525 | 530 | 531 | Image 532 | {/* ID */} 533 | Title 534 | ProductCode 535 | Quantity 536 | Quantity Actions 537 | ProductID 538 | ProductModel 539 | Description 540 | ParentCatalogName 541 | CatalogName 542 | BrandName 543 | EncapStandard 544 | Pdf 545 | Link 546 | Price 547 | Voltage 548 | Resistance 549 | Power 550 | Current 551 | Tolerance 552 | Frequency 553 | Capacitance 554 | Inductance 555 | Delete 556 | 557 | 558 | 559 | {parts.map((element) => ( 560 | 569 | ))} 570 | 571 |
572 |
573 | ) : ( 574 | 575 | No Parts Found 576 | 577 | )} 578 | 579 | { 583 | await navigatePage(value); 584 | }} 585 | /> 586 | ({itemCountState}) 587 | 588 |
589 |
590 | 591 | ); 592 | } 593 | 594 | function PartItem({ 595 | part, 596 | isLoading, 597 | updatePartQuantity, 598 | deletePart, 599 | }: { 600 | part: PartState; 601 | isLoading: boolean; 602 | updatePartQuantity: (partId: number, quantity: number) => Promise; 603 | deletePart: (partId: number) => Promise; 604 | }) { 605 | const addQuantityRef = useRef(null); 606 | const removeQuantityRef = useRef(null); 607 | return ( 608 | 609 | 610 | {part.title} 616 | 617 | 618 | 619 | {part.title} 620 | 626 | 627 | 628 | } 629 | /> 630 | 631 | 632 | {part.productCode} 633 | {part.quantity} 634 | 635 | 636 | 637 | Add 638 | Remove 639 | 640 | 641 | 642 | 650 | 665 | 666 | 667 | 668 | 669 | 678 | 695 | 696 | 697 | 698 | 699 | {part.productId} 700 | {part.productModel} 701 | {part.productDescription} 702 | {part.parentCatalogName} 703 | {part.catalogName} 704 | {part.brandName} 705 | {part.encapStandard} 706 | 707 | {part.pdfLink ? ( 708 | 715 | 716 | 717 | } 718 | /> 719 | ) : ( 720 | <> 721 | )} 722 | 723 | 724 | {part.productLink ? ( 725 | 732 | 733 | 734 | } 735 | /> 736 | ) : ( 737 | <> 738 | )} 739 | 740 | 741 | 742 | 743 | {part.prices.at(0)?.price 744 | ? part.prices.at(0)?.price + "$" 745 | : ""} 746 | 747 | 748 | 749 | 750 | 751 | 752 | Quantity 753 | Price 754 | 755 | 756 | 757 | {part.prices.map((price) => ( 758 | 759 | {price.ladder} 760 | {price.price + "$"} 761 | 762 | ))} 763 | 764 |
765 |
766 |
767 | {formatVoltage(part.voltage)} 768 | {formatResistance(part.resistance)} 769 | {formatPower(part.power)} 770 | {formatCurrent(part.current)} 771 | {part.tolerance} 772 | {formatFrequency(part.frequency)} 773 | {formatCapacitance(part.capacitance)} 774 | {formatInductance(part.inductance)} 775 | 776 | 790 | 791 |
792 | ); 793 | } 794 | 795 | const formatVoltage = (voltage: any) => 796 | `${ 797 | voltage < 1 798 | ? round(unit(Number(voltage), "V").toNumeric("mV")) + " mV" 799 | : format(voltage, { lowerExp: -2, upperExp: 2 }) + " V" 800 | }`; 801 | const formatResistance = (resistance: any) => 802 | `${ 803 | resistance < 1 804 | ? round(unit(Number(resistance), "ohm").toNumeric("mohm")) + " mΩ" 805 | : resistance >= 1000 806 | ? round(unit(Number(resistance), "ohm").toNumeric("kohm")) + " kΩ" 807 | : format(resistance, { lowerExp: -2, upperExp: 2 }) + " Ω" 808 | }`; 809 | const formatPower = (power: any) => 810 | `${ 811 | power < 1 812 | ? round(unit(Number(power), "W").toNumeric("mW")) + " mW" 813 | : format(power, { lowerExp: -2, upperExp: 2 }) + " W" 814 | }`; 815 | const formatCurrent = (current: any) => 816 | `${ 817 | current < 1 818 | ? round(unit(Number(current), "A").toNumeric("mA")) + " mA" 819 | : format(current, { lowerExp: -2, upperExp: 2 }) + " A" 820 | }`; 821 | const formatFrequency = (frequency: any) => 822 | `${ 823 | frequency < 1 824 | ? round(unit(Number(frequency), "Hz").toNumeric("mHz")) + " mHz" 825 | : frequency >= 1000000 826 | ? round(unit(Number(frequency), "Hz").toNumeric("MHz")) + " MHz" 827 | : frequency >= 1000 828 | ? round(unit(Number(frequency), "Hz").toNumeric("kHz")) + " kHz" 829 | : format(frequency, { lowerExp: -2, upperExp: 2 }) + " Hz" 830 | }`; 831 | const formatCapacitance = (capacitance: any) => { 832 | return `${round(unit(Number(capacitance), "pF").toNumeric("nF"))} nF`; 833 | }; 834 | const formatInductance = (inductance: any) => { 835 | return `${round(Number(inductance ?? null))} uH`; 836 | }; 837 | --------------------------------------------------------------------------------