├── src ├── hooks │ ├── index.ts │ └── use-scroll.ts ├── lib │ ├── helpers │ │ ├── index.ts │ │ └── format-to-id.ts │ └── utils │ │ ├── constants.ts │ │ ├── trpc │ │ ├── client.ts │ │ └── provider.tsx │ │ ├── schema.ts │ │ ├── tw.ts │ │ └── configured-ofetch.ts ├── app │ ├── favicon.ico │ ├── sitemap.ts │ ├── robots.ts │ ├── error-client.tsx │ ├── not-found.tsx │ ├── api │ │ └── trpc │ │ │ └── [trpc] │ │ │ └── route.ts │ ├── error.tsx │ ├── wrapper.tsx │ ├── mahasiswa │ │ └── [slug] │ │ │ ├── loading.tsx │ │ │ └── page.tsx │ ├── perguruan-tinggi │ │ └── [slug] │ │ │ ├── loading.tsx │ │ │ ├── client.tsx │ │ │ └── page.tsx │ ├── is-refetching.tsx │ ├── page.tsx │ ├── loading-client.tsx │ ├── globals.css │ ├── layout.tsx │ └── client.tsx ├── components │ ├── ui │ │ ├── typography │ │ │ ├── index.ts │ │ │ ├── paragraph.tsx │ │ │ └── heading.tsx │ │ ├── card.tsx │ │ ├── image.tsx │ │ ├── button.tsx │ │ ├── table.tsx │ │ ├── dialog.tsx │ │ └── dropdown-menu.tsx │ ├── map.tsx │ ├── footer.tsx │ ├── back-to-top.tsx │ ├── breadcumbs.tsx │ ├── lightbox.tsx │ └── switch-theme.tsx ├── store │ └── index.ts ├── types │ ├── mahasiswa.type.ts │ ├── index.ts │ ├── components.type.ts │ ├── detail-mahasiswa.type.ts │ └── detail-perguruan-tinggi.type.ts ├── middleware.ts ├── env.mjs ├── features │ └── index.ts └── server │ └── index.ts ├── public ├── ss-1.png ├── ss-2.png ├── ss-3.png ├── banner.png ├── website-structure.png └── user.svg ├── .github └── FUNDING.yml ├── postcss.config.js ├── .env.example ├── .dockerignore ├── cypress ├── fixtures │ └── example.json ├── components │ ├── card.cy.tsx │ └── switch-theme.cy.tsx ├── support │ ├── component-index.html │ ├── e2e.ts │ ├── component.ts │ └── commands.ts └── e2e │ └── homepage.cy.ts ├── turbo.json ├── .editorconfig ├── docker-compose.yaml ├── Dockerfile ├── cypress.config.ts ├── components.json ├── .eslintrc.json ├── .gitignore ├── next.config.mjs ├── prettier.config.js ├── tsconfig.json ├── LICENSE ├── README.md ├── package.json └── tailwind.config.ts /src/hooks/index.ts: -------------------------------------------------------------------------------- 1 | export { useScroll } from "./use-scroll"; 2 | -------------------------------------------------------------------------------- /src/lib/helpers/index.ts: -------------------------------------------------------------------------------- 1 | export { formatToID } from "./format-to-id"; 2 | -------------------------------------------------------------------------------- /src/lib/utils/constants.ts: -------------------------------------------------------------------------------- 1 | export const CONDITION = process.env.NODE_ENV; 2 | -------------------------------------------------------------------------------- /public/ss-1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/haikelz/cari-mahasiswa/HEAD/public/ss-1.png -------------------------------------------------------------------------------- /public/ss-2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/haikelz/cari-mahasiswa/HEAD/public/ss-2.png -------------------------------------------------------------------------------- /public/ss-3.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/haikelz/cari-mahasiswa/HEAD/public/ss-3.png -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: [haikelz] 2 | custom: ["https://trakteer.id/haikelz/tip"] 3 | -------------------------------------------------------------------------------- /public/banner.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/haikelz/cari-mahasiswa/HEAD/public/banner.png -------------------------------------------------------------------------------- /src/app/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/haikelz/cari-mahasiswa/HEAD/src/app/favicon.ico -------------------------------------------------------------------------------- /public/website-structure.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/haikelz/cari-mahasiswa/HEAD/public/website-structure.png -------------------------------------------------------------------------------- /postcss.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: { 3 | tailwindcss: {}, 4 | autoprefixer: {}, 5 | }, 6 | } 7 | -------------------------------------------------------------------------------- /src/components/ui/typography/index.ts: -------------------------------------------------------------------------------- 1 | export { Heading } from "./heading"; 2 | export { Paragraph } from "./paragraph"; 3 | -------------------------------------------------------------------------------- /.env.example: -------------------------------------------------------------------------------- 1 | NEXT_PUBLIC_API_URL 2 | NEXT_PUBLIC_DEVELOPMENT_URL 3 | NEXT_PUBLIC_PRODUCTION_URL 4 | NEXT_TELEMETRY_DISABLED 5 | -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | cypress 2 | .git 3 | .github 4 | README.md 5 | LICENSE 6 | cypress 7 | cypress.config.ts 8 | node_modules 9 | .next 10 | -------------------------------------------------------------------------------- /src/lib/utils/trpc/client.ts: -------------------------------------------------------------------------------- 1 | import { createTRPCReact } from "@trpc/react-query"; 2 | import { AppRouter } from "~server"; 3 | 4 | export const trpc = createTRPCReact({}); 5 | -------------------------------------------------------------------------------- /cypress/fixtures/example.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Using fixtures to represent data", 3 | "email": "hello@cypress.io", 4 | "body": "Fixtures are a great way to mock data for responses to routes" 5 | } 6 | -------------------------------------------------------------------------------- /src/store/index.ts: -------------------------------------------------------------------------------- 1 | import { atom } from "jotai"; 2 | 3 | export const scrollAtom = atom(0); 4 | export const isOpenLogoAtom = atom(false); 5 | export const isOpenMapAtom = atom(false); 6 | -------------------------------------------------------------------------------- /turbo.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://turbo.build/schema.json", 3 | "pipeline": { 4 | "build": { 5 | "outputs": [".next/**", "!.next/cache/**"] 6 | }, 7 | "lint": {} 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /cypress/components/card.cy.tsx: -------------------------------------------------------------------------------- 1 | import Card from "~components/ui/card"; 2 | 3 | describe("Card", () => { 4 | it("Should display card component and test it", () => { 5 | cy.mount(); 6 | 7 | cy.get(`[data-cy="card"]`).should("be.visible"); 8 | }); 9 | }); 10 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | indent_size = 2 5 | indent_style = space 6 | insert_final_newline = true 7 | trim_trailing_whitespace = true 8 | insert_final_newline = true 9 | end_of_line = lf 10 | charset = utf-8 11 | 12 | [*.md] 13 | trim_trailing_whitespace = false 14 | -------------------------------------------------------------------------------- /docker-compose.yaml: -------------------------------------------------------------------------------- 1 | version: "3.5" 2 | 3 | services: 4 | app: 5 | build: 6 | context: . 7 | dockerfile: Dockerfile 8 | container_name: cari-mahasiswa 9 | ports: 10 | - "3000:3000" 11 | volumes: 12 | - .:/app 13 | - /app/node_modules 14 | -------------------------------------------------------------------------------- /src/types/mahasiswa.type.ts: -------------------------------------------------------------------------------- 1 | export type BaseMahasiswaProps = { 2 | nama: string; 3 | pt: string; 4 | prodi: string; 5 | hash: string; 6 | }; 7 | 8 | export type MahasiswaProps = { 9 | mahasiswa: { 10 | text: string; 11 | "website-link": string; 12 | }[]; 13 | }; 14 | -------------------------------------------------------------------------------- /src/middleware.ts: -------------------------------------------------------------------------------- 1 | import { NextRequest, NextResponse } from "next/server"; 2 | 3 | export function middleware(req: NextRequest) { 4 | return NextResponse.redirect(new URL("/", req.url)); 5 | } 6 | 7 | export const config = { 8 | matcher: ["/mahasiswa", "/perguruan-tinggi"], 9 | }; 10 | -------------------------------------------------------------------------------- /src/types/index.ts: -------------------------------------------------------------------------------- 1 | import { ReactNode } from "react"; 2 | 3 | export type ChildrenProps = { 4 | children: ReactNode; 5 | }; 6 | 7 | export * from "./components.type"; 8 | export * from "./detail-mahasiswa.type"; 9 | export * from "./detail-perguruan-tinggi.type"; 10 | export * from "./mahasiswa.type"; 11 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:alpine AS build 2 | 3 | RUN npm install -g pnpm turbo 4 | WORKDIR /app 5 | ENV PNPM_HOME="/pnpm" 6 | ENV PATH="$PNPM_HOME:$PATH" 7 | 8 | COPY package.json pnpm-lock.yaml ./ 9 | RUN pnpm install 10 | 11 | COPY . ./ 12 | RUN turbo run build 13 | 14 | # run dev 15 | CMD ["turbo", "run", "dev"] 16 | -------------------------------------------------------------------------------- /src/app/sitemap.ts: -------------------------------------------------------------------------------- 1 | import { MetadataRoute } from "next"; 2 | 3 | export default function Sitemap(): MetadataRoute.Sitemap { 4 | return [ 5 | { 6 | url: "https://crmhs.ekel.dev", 7 | lastModified: new Date(), 8 | changeFrequency: "yearly", 9 | priority: 1, 10 | }, 11 | ]; 12 | } 13 | -------------------------------------------------------------------------------- /src/lib/utils/schema.ts: -------------------------------------------------------------------------------- 1 | import { z } from "zod"; 2 | 3 | export const schema = z.object({ 4 | value: z 5 | .string() 6 | .min(1, { message: "The characters length must be at least 1 character!" }) 7 | .regex(/[\w]/gi, { 8 | message: "The characters must be alphabet, or number!", 9 | }), 10 | }); 11 | -------------------------------------------------------------------------------- /cypress.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from "cypress"; 2 | 3 | export default defineConfig({ 4 | e2e: { 5 | setupNodeEvents() {}, 6 | baseUrl: "http://localhost:3000", 7 | }, 8 | 9 | component: { 10 | devServer: { 11 | framework: "next", 12 | bundler: "webpack", 13 | }, 14 | }, 15 | }); 16 | -------------------------------------------------------------------------------- /cypress/components/switch-theme.cy.tsx: -------------------------------------------------------------------------------- 1 | import SwitchTheme from "~components/switch-theme"; 2 | 3 | describe("Switch Theme ", () => { 4 | it("Should display switch theme component and test it", () => { 5 | cy.mount(); 6 | 7 | cy.get(`[aria-label="switch theme"]`).should("be.visible"); 8 | }); 9 | }); 10 | -------------------------------------------------------------------------------- /src/app/robots.ts: -------------------------------------------------------------------------------- 1 | import { MetadataRoute } from "next"; 2 | 3 | export default function Robots(): MetadataRoute.Robots { 4 | return { 5 | rules: { 6 | userAgent: "*", 7 | allow: "/", 8 | }, 9 | host: "https://crmhs.ekel.dev/", 10 | sitemap: "https://crmhs.ekel.dev/sitemap.xml", 11 | }; 12 | } 13 | -------------------------------------------------------------------------------- /src/components/ui/typography/paragraph.tsx: -------------------------------------------------------------------------------- 1 | import { tw } from "~lib/utils/tw"; 2 | import type { ParagraphProps } from "~types"; 3 | 4 | export function Paragraph({ className, children, ...props }: ParagraphProps) { 5 | return ( 6 |

7 | {children} 8 |

9 | ); 10 | } 11 | -------------------------------------------------------------------------------- /src/lib/utils/tw.ts: -------------------------------------------------------------------------------- 1 | import { cx } from "class-variance-authority"; 2 | import { ClassValue } from "clsx"; 3 | import { twMerge } from "tailwind-merge"; 4 | 5 | /** 6 | * Merge tailwind classes 7 | * @param {ClassValue[]} classes 8 | * @returns classes 9 | */ 10 | export const tw = (...classes: ClassValue[]) => twMerge(cx(...classes)); 11 | -------------------------------------------------------------------------------- /src/lib/utils/configured-ofetch.ts: -------------------------------------------------------------------------------- 1 | import { ofetch } from "ofetch"; 2 | 3 | export const configuredOfetch = (link: string): Promise => 4 | ofetch(link, { 5 | method: "GET", 6 | parseResponse: JSON.parse, 7 | responseType: "json", 8 | headers: { 9 | "Content-Type": "application/json", 10 | Accept: "application/json", 11 | }, 12 | }); 13 | -------------------------------------------------------------------------------- /src/lib/helpers/format-to-id.ts: -------------------------------------------------------------------------------- 1 | import { format, parseISO } from "date-fns"; 2 | import { id } from "date-fns/locale"; 3 | 4 | /** 5 | * A helper function to format UTC date to ID format 6 | * @param {string} str utc date 7 | * @returns {string} formatted date 8 | */ 9 | export const formatToID = (str: string): string => 10 | format(parseISO(str), "dd MMMM yyyy", { locale: id }); 11 | -------------------------------------------------------------------------------- /components.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://ui.shadcn.com/schema.json", 3 | "style": "default", 4 | "rsc": true, 5 | "tsx": true, 6 | "tailwind": { 7 | "config": "tailwind.config.ts", 8 | "css": "app/globals.css", 9 | "baseColor": "neutral", 10 | "cssVariables": true 11 | }, 12 | "aliases": { 13 | "components": "~components", 14 | "utils": "~lib/utils/tw" 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": [ 3 | "next", 4 | "next/core-web-vitals", 5 | "prettier", 6 | "plugin:jsx-a11y/recommended" 7 | ], 8 | "rules": { 9 | "@next/next/no-html-link-for-pages": "off" 10 | }, 11 | "parserOptions": { 12 | "babelOptions": { 13 | "presets": [ 14 | "next/babel" 15 | ] 16 | } 17 | }, 18 | "plugins": [ 19 | "jsx-a11y" 20 | ] 21 | } -------------------------------------------------------------------------------- /src/app/error-client.tsx: -------------------------------------------------------------------------------- 1 | import { Heading, Paragraph } from "~components/ui/typography"; 2 | 3 | export default function ErrorClient() { 4 | return ( 5 |
6 |
7 | Error! 8 | Terdapat masalah saat fetch data! 9 |
10 |
11 | ); 12 | } 13 | -------------------------------------------------------------------------------- /src/app/not-found.tsx: -------------------------------------------------------------------------------- 1 | import { Heading, Paragraph } from "~components/ui/typography"; 2 | 3 | export default function NotFoundPage() { 4 | return ( 5 |
6 |
7 | 404 Not Found 8 | Halaman yang kamu cari tidak ditemukan! 9 |
10 |
11 | ); 12 | } 13 | -------------------------------------------------------------------------------- /cypress/support/component-index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Components App 8 | 9 |
10 | 11 | 12 |
13 | 14 | -------------------------------------------------------------------------------- /src/app/api/trpc/[trpc]/route.ts: -------------------------------------------------------------------------------- 1 | import { fetchRequestHandler } from "@trpc/server/adapters/fetch"; 2 | import { appRouter } from "~server"; 3 | 4 | async function handler(req: Request): Promise { 5 | const response = await fetchRequestHandler({ 6 | endpoint: "/api/trpc", 7 | req, 8 | router: appRouter, 9 | createContext: () => ({}), 10 | }); 11 | 12 | return response; 13 | } 14 | 15 | export { handler as GET, handler as POST }; 16 | -------------------------------------------------------------------------------- /src/app/error.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { Heading, Paragraph } from "~components/ui/typography"; 4 | 5 | export default function ErrorPage() { 6 | return ( 7 |
8 |
9 | 500 Server Error 10 | Sepertinya ada permasalahan pada server! 11 |
12 |
13 | ); 14 | } 15 | -------------------------------------------------------------------------------- /src/components/map.tsx: -------------------------------------------------------------------------------- 1 | import { MapProps } from "~types"; 2 | 3 | export default function Map({ lat, long }: MapProps) { 4 | return ( 5 | 13 | ); 14 | } 15 | -------------------------------------------------------------------------------- /.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 | .turbo 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 | # env 39 | .env 40 | -------------------------------------------------------------------------------- /src/env.mjs: -------------------------------------------------------------------------------- 1 | import { createEnv } from "@t3-oss/env-nextjs"; 2 | import { z } from "zod"; 3 | 4 | export const env = createEnv({ 5 | client: { 6 | NEXT_PUBLIC_API_URL: z.string().min(1).url(), 7 | NEXT_PUBLIC_PRODUCTION_URL: z.string().min(1).url(), 8 | NEXT_PUBLIC_DEVELOPMENT_URL: z.string().min(1).url(), 9 | }, 10 | experimental__runtimeEnv: { 11 | NEXT_PUBLIC_API_URL: process.env.NEXT_PUBLIC_API_URL, 12 | NEXT_PUBLIC_PRODUCTION_URL: process.env.NEXT_PUBLIC_PRODUCTION_URL, 13 | NEXT_PUBLIC_DEVELOPMENT_URL: process.env.NEXT_PUBLIC_DEVELOPMENT_URL, 14 | }, 15 | }); 16 | -------------------------------------------------------------------------------- /next.config.mjs: -------------------------------------------------------------------------------- 1 | import "./src/env.mjs"; 2 | import { env } from "./src/env.mjs"; 3 | 4 | const { NEXT_PUBLIC_API_URL } = env; 5 | 6 | const logo = NEXT_PUBLIC_API_URL.replace("https://", ""); 7 | 8 | /** @type {import('next').NextConfig} */ 9 | const nextConfig = { 10 | reactStrictMode: true, 11 | compress: true, 12 | images: { 13 | remotePatterns: [ 14 | { 15 | protocol: "https", 16 | hostname: "placehold.co", 17 | }, 18 | { 19 | protocol: "https", 20 | hostname: logo, 21 | }, 22 | ], 23 | dangerouslyAllowSVG: true, 24 | }, 25 | }; 26 | 27 | export default nextConfig; 28 | -------------------------------------------------------------------------------- /src/app/wrapper.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { Provider as JotaiProvider } from "jotai"; 4 | import { ThemeProvider } from "next-themes"; 5 | import Provider from "~lib/utils/trpc/provider"; 6 | import type { ChildrenProps } from "~types"; 7 | 8 | export default function Wrapper({ children }: ChildrenProps) { 9 | return ( 10 | 11 | 12 | 18 | {children} 19 | 20 | 21 | 22 | ); 23 | } 24 | -------------------------------------------------------------------------------- /src/components/footer.tsx: -------------------------------------------------------------------------------- 1 | import Link from "next/link"; 2 | 3 | export default function Footer() { 4 | return ( 5 |
6 |
7 | 8 | Dibangun oleh{" "} 9 | 15 | Haikel 16 | 17 | , dengan ☕. 18 | 19 |
20 |
21 | ); 22 | } 23 | -------------------------------------------------------------------------------- /src/components/ui/card.tsx: -------------------------------------------------------------------------------- 1 | import { tw } from "~lib/utils/tw"; 2 | import { CardProps } from "~types"; 3 | 4 | export default function Card({ children, className, ...props }: CardProps) { 5 | return ( 6 |
7 |
18 | {children} 19 |
20 |
21 | ); 22 | } 23 | -------------------------------------------------------------------------------- /prettier.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | arrowParens: "always", 3 | bracketSpacing: true, 4 | endOfLine: "lf", 5 | htmlWhitespaceSensitivity: "css", 6 | insertPragma: false, 7 | jsxSingleQuote: false, 8 | printWidth: 80, 9 | proseWrap: "preserve", 10 | quoteProps: "as-needed", 11 | requirePragma: false, 12 | semi: true, 13 | singleQuote: false, 14 | tabWidth: 2, 15 | trailingComma: "es5", 16 | useTabs: false, 17 | vueIndentScriptAndStyle: false, 18 | plugins: ["@trivago/prettier-plugin-sort-imports"], 19 | importOrder: ["^@core/(.*)$", "^@server/(.*)$", "^@ui/(.*)$", "^[./]"], 20 | importOrderSeparation: true, 21 | importOrderSortSpecifiers: true, 22 | }; 23 | -------------------------------------------------------------------------------- /cypress/support/e2e.ts: -------------------------------------------------------------------------------- 1 | // *********************************************************** 2 | // This example support/e2e.ts is processed and 3 | // loaded automatically before your test files. 4 | // 5 | // This is a great place to put global configuration and 6 | // behavior that modifies Cypress. 7 | // 8 | // You can change the location of this file or turn off 9 | // automatically serving support files with the 10 | // 'supportFile' configuration option. 11 | // 12 | // You can read more here: 13 | // https://on.cypress.io/configuration 14 | // *********************************************************** 15 | 16 | // Import commands.js using ES2015 syntax: 17 | import './commands' 18 | 19 | // Alternatively you can use CommonJS syntax: 20 | // require('./commands') -------------------------------------------------------------------------------- /src/hooks/use-scroll.ts: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { useAtom } from "jotai"; 4 | import { useCallback, useEffect } from "react"; 5 | import { scrollAtom } from "~store"; 6 | 7 | /** 8 | * A custom hook to detect user's scroll 9 | * @returns {number} scroll value 10 | */ 11 | export function useScroll(): number { 12 | const [scroll, setScroll] = useAtom(scrollAtom); 13 | 14 | const handleScroll = useCallback(() => { 15 | const position = window.scrollY; 16 | setScroll(() => position); 17 | }, [setScroll]); 18 | 19 | useEffect(() => { 20 | window.addEventListener("scroll", handleScroll, { passive: true }); 21 | return () => window.removeEventListener("scroll", handleScroll); 22 | }, [handleScroll]); 23 | 24 | return scroll; 25 | } 26 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ESNext", 4 | "lib": [ 5 | "dom", 6 | "dom.iterable", 7 | "esnext" 8 | ], 9 | "allowJs": true, 10 | "skipLibCheck": true, 11 | "strict": true, 12 | "noEmit": true, 13 | "esModuleInterop": true, 14 | "module": "esnext", 15 | "moduleResolution": "Node", 16 | "resolveJsonModule": true, 17 | "isolatedModules": true, 18 | "jsx": "preserve", 19 | "incremental": true, 20 | "plugins": [ 21 | { 22 | "name": "next" 23 | } 24 | ], 25 | "paths": { 26 | "~*": [ 27 | "./src/*" 28 | ] 29 | } 30 | }, 31 | "include": [ 32 | "next-env.d.ts", 33 | "**/*.ts", 34 | "**/*.tsx", 35 | ".next/types/**/*.ts" 36 | ], 37 | "exclude": [ 38 | "node_modules" 39 | ] 40 | } -------------------------------------------------------------------------------- /src/components/ui/typography/heading.tsx: -------------------------------------------------------------------------------- 1 | import { HeadingProps } from "~types"; 2 | 3 | export function Heading({ as, children }: HeadingProps) { 4 | return ( 5 | <> 6 | {as === "h1" ? ( 7 |

8 | {children} 9 |

10 | ) : as === "h2" ? ( 11 |

12 | {children} 13 |

14 | ) : as === "h3" ? ( 15 |

16 | {children} 17 |

18 | ) : as === "h4" ? ( 19 |

20 | {children} 21 |

22 | ) : null} 23 | 24 | ); 25 | } 26 | -------------------------------------------------------------------------------- /src/types/components.type.ts: -------------------------------------------------------------------------------- 1 | import { SetStateAction } from "jotai"; 2 | import { ImageProps } from "next/image"; 3 | import { Dispatch, HTMLAttributes } from "react"; 4 | import { ChildrenProps } from "~types"; 5 | 6 | export type LightboxProps = { 7 | isOpen: boolean; 8 | setIsOpen: Dispatch>; 9 | } & ChildrenProps; 10 | 11 | export type LogoDetailProps = { 12 | src: string; 13 | alt: string; 14 | }; 15 | 16 | export type MapProps = { 17 | lat: T; 18 | long: T; 19 | }; 20 | 21 | export type HeadingProps = HTMLAttributes & { 22 | as: "h1" | "h2" | "h3" | "h4"; 23 | }; 24 | 25 | export type ParagraphProps = ChildrenProps & 26 | HTMLAttributes; 27 | 28 | export type CardProps = HTMLAttributes & { 29 | className?: string; 30 | }; 31 | 32 | export type NextImageProps = ImageProps & { 33 | isBase64: boolean; 34 | }; 35 | -------------------------------------------------------------------------------- /src/components/back-to-top.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { ArrowUpIcon } from "lucide-react"; 4 | import { P, match } from "ts-pattern"; 5 | import { useScroll } from "~hooks"; 6 | 7 | import { Button } from "./ui/button"; 8 | 9 | export default function BackToTop() { 10 | const scroll = useScroll(); 11 | 12 | return ( 13 | <> 14 | {match({ scroll: scroll }) 15 | .with({ scroll: P.when((scroll) => scroll > 50) }, () => ( 16 | 27 | )) 28 | .otherwise(() => null)} 29 | 30 | ); 31 | } 32 | -------------------------------------------------------------------------------- /src/types/detail-mahasiswa.type.ts: -------------------------------------------------------------------------------- 1 | export type DetailMahasiswaProps = { 2 | datastatuskuliah: DataStatusKuliahProps[]; 3 | datastudi: DataStudiProps[]; 4 | dataumum: DataUmumProps; 5 | }; 6 | 7 | export type DataStatusKuliahProps = { 8 | id_smt: string; 9 | sks_smt: number; 10 | nm_stat_mhs: string; 11 | }; 12 | 13 | export type DataStudiProps = { 14 | kode_mk: string; 15 | nm_mk: string; 16 | sks_mk: number; 17 | id_smt: string; 18 | nilai_huruf: string; 19 | }; 20 | 21 | export type DataUmumProps = { 22 | nm_pd: string; 23 | jk: "L" | "P"; 24 | nipd: string; 25 | namapt: string; 26 | namajenjang: string; 27 | namaprodi: string; 28 | reg_pd: string; 29 | mulai_smt: string; 30 | nm_jns_daftar: string; 31 | nm_pt_asal: string; 32 | nm_prodi_asal: string; 33 | ket_keluar: string; 34 | tgl_keluar: string; 35 | no_seri_ijazah: string; 36 | sert_prof: string; 37 | link_pt: string; 38 | link_prodi: string; 39 | }; 40 | -------------------------------------------------------------------------------- /src/components/ui/image.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { atom, useAtom } from "jotai"; 4 | import { StaticImport } from "next/dist/shared/lib/get-img-props"; 5 | import NextImage from "next/image"; 6 | import { useMemo } from "react"; 7 | import { tw } from "~lib/utils/tw"; 8 | import { NextImageProps } from "~types"; 9 | 10 | export default function Image( 11 | { className, src, alt, width, height, isBase64, ...props }: NextImageProps 12 | ) { 13 | const imgSrcAtom = useMemo(() => atom(src), [src]); 14 | const [imgSrc, setImgSrc] = useAtom(imgSrcAtom); 15 | 16 | return ( 17 | 21 | setImgSrc( 22 | `https://placehold.co/300?text=Image+Not+Found&font=montserrat` 23 | ) 24 | } 25 | className={tw(className)} 26 | width={width} 27 | height={height} 28 | {...props} 29 | /> 30 | ); 31 | } 32 | -------------------------------------------------------------------------------- /src/app/mahasiswa/[slug]/loading.tsx: -------------------------------------------------------------------------------- 1 | export default function Loading() { 2 | return ( 3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 | ); 17 | } 18 | -------------------------------------------------------------------------------- /src/app/perguruan-tinggi/[slug]/loading.tsx: -------------------------------------------------------------------------------- 1 | export default function Loading() { 2 | return ( 3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 | ); 19 | } 20 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 Haikel Ilham Hakim 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /src/components/breadcumbs.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { ChevronRight } from "lucide-react"; 4 | import Link from "next/link"; 5 | import { usePathname } from "next/navigation"; 6 | import { Fragment } from "react"; 7 | import { tw } from "~lib/utils/tw"; 8 | 9 | export default function Breadcrumbs() { 10 | const pathname = usePathname(); 11 | const pathnames = pathname.slice(1).split("/"); 12 | 13 | return ( 14 | 20 | home 21 | 22 | {pathnames.slice(0, pathnames.length - 1).map((item, index) => ( 23 | 24 | {item} 25 | 26 | 27 | ))} 28 | 32 | {pathnames[pathnames.length - 1].slice(0, 10)}... 33 | 34 | 35 | ); 36 | } 37 | -------------------------------------------------------------------------------- /src/types/detail-perguruan-tinggi.type.ts: -------------------------------------------------------------------------------- 1 | type AkreditasiListProps = { 2 | akreditasi: string; 3 | tgl_akreditasi: string; 4 | tgl_berlaku: string; 5 | }; 6 | 7 | export type DetailPerguruanTinggiProps = { 8 | npsn: string; 9 | stat_sp: string; 10 | nm_lemb: string; 11 | tgl_berdiri: string; 12 | sk_pendirian_sp: string; 13 | tgl_sk_pendirian_sp: string; 14 | jln: string; 15 | nama_wil: string; 16 | kode_pos: string; 17 | no_tel: string; 18 | no_fax: string; 19 | email: string; 20 | website: string; 21 | lintang: number; 22 | bujur: number; 23 | id_sp: string; 24 | luas_tanah: number; 25 | laboratorium: number; 26 | ruang_kelas: number; 27 | perpustakaan: number; 28 | internet: boolean; 29 | listrik: boolean; 30 | nama_rektor: string; 31 | akreditasi_list: AkreditasiListProps[]; 32 | }; 33 | 34 | type RasioListProps = { 35 | semester: string; 36 | dosen: number; 37 | mahasiswa: number; 38 | dosenNidn: number; 39 | dosenNidk: number; 40 | }; 41 | 42 | export type ProdiPerguruanTinggiProps = { 43 | id_sms: string; 44 | kode_prodi: string; 45 | nm_lemb: string; 46 | stat_prodi: string; 47 | jenjang: string; 48 | akreditas: string | null; 49 | rasio_list: RasioListProps[]; 50 | }; 51 | -------------------------------------------------------------------------------- /src/lib/utils/trpc/provider.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; 4 | import { httpBatchLink } from "@trpc/react-query"; 5 | import { atom, useAtom } from "jotai"; 6 | import { env } from "~env.mjs"; 7 | import type { ChildrenProps } from "~types"; 8 | 9 | import { CONDITION } from "../constants"; 10 | import { trpc } from "./client"; 11 | 12 | const { NEXT_PUBLIC_DEVELOPMENT_URL, NEXT_PUBLIC_PRODUCTION_URL } = env; 13 | 14 | const queryClientAtom = atom(() => new QueryClient({})); 15 | const trpcClientAtom = atom(() => 16 | trpc.createClient({ 17 | links: [ 18 | httpBatchLink({ 19 | url: `${ 20 | CONDITION === "development" 21 | ? NEXT_PUBLIC_DEVELOPMENT_URL 22 | : NEXT_PUBLIC_PRODUCTION_URL 23 | }/api/trpc`, 24 | }), 25 | ], 26 | }) 27 | ); 28 | 29 | export default function Provider({ children }: ChildrenProps) { 30 | const [queryClient] = useAtom(queryClientAtom); 31 | const [trpcClient] = useAtom(trpcClientAtom); 32 | 33 | return ( 34 | 35 | {children} 36 | 37 | ); 38 | } 39 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |
2 |

Cari Mahasiswa

3 |

Cari Mahasiswa adalah sebuah Website untuk mencari data Mahasiswa dari berbagai perguruan tinggi di Indonesia

4 |
5 | 6 | ## Features 7 | 8 | - Cari mahasiswa berdasarkan NIM, nama, jurusan, serta nama perguruan tinggi. 9 | - Tampilkan detail mahasiswa. 10 | - Tampilkan detail perguruan tinggi. 11 | - Switch theme(dark, light, system). 12 | 13 | ## Website Structure 14 | 15 | ![website structure](public/website-structure.png) 16 | 17 | ## Screenshots 18 | 19 | ![ss 1](./public/ss-1.png) 20 | 21 | ![ss 2](./public/ss-2.png) 22 | 23 | ![ss 3](./public/ss-3.png) 24 | 25 | **Note:** Data mahasiswa dan perguruan tinggi di atas hanya sebagai contoh. 26 | 27 | ## Videos 28 | 29 | [![Video](public/banner.png)](https://youtu.be/aVuWC-usk7c?feature=shared) 30 | 31 | ## Tech Stack 32 | 33 | - Next JS 34 | - Typescript 35 | - Tailwind CSS with shadcn/ui 36 | - React Query 37 | - tRPC 38 | 39 | ## Getting Started 40 | 41 | - Clone this repo. 42 | - Install all needed deps with `pnpm install`. 43 | - Fill all needed environment variable. You can see the format of my env in `.env.example` file. 44 | - Type `pnpm run dev` and see the result in `http://localhost:3000`. 45 | 46 | ## License 47 | 48 | [MIT](https://github.com/haikelz/money-management/blob/master/LICENSE) 49 | -------------------------------------------------------------------------------- /cypress/support/component.ts: -------------------------------------------------------------------------------- 1 | // *********************************************************** 2 | // This example support/component.ts is processed and 3 | // loaded automatically before your test files. 4 | // 5 | // This is a great place to put global configuration and 6 | // behavior that modifies Cypress. 7 | // 8 | // You can change the location of this file or turn off 9 | // automatically serving support files with the 10 | // 'supportFile' configuration option. 11 | // 12 | // You can read more here: 13 | // https://on.cypress.io/configuration 14 | // *********************************************************** 15 | 16 | // Import commands.js using ES2015 syntax: 17 | import "../../src/app/globals.css"; 18 | import "./commands"; 19 | 20 | // Alternatively you can use CommonJS syntax: 21 | // require('./commands') 22 | 23 | import { mount } from "cypress/react18"; 24 | 25 | // Augment the Cypress namespace to include type definitions for 26 | // your custom command. 27 | // Alternatively, can be defined in cypress/support/component.d.ts 28 | // with a at the top of your spec. 29 | declare global { 30 | namespace Cypress { 31 | interface Chainable { 32 | mount: typeof mount; 33 | } 34 | } 35 | } 36 | 37 | Cypress.Commands.add("mount", mount); 38 | 39 | // Example use: 40 | // cy.mount() 41 | -------------------------------------------------------------------------------- /src/components/lightbox.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { XIcon } from "lucide-react"; 4 | import { match } from "ts-pattern"; 5 | import { tw } from "~lib/utils/tw"; 6 | import { LightboxProps } from "~types"; 7 | 8 | export default function Lightbox( 9 | { isOpen, setIsOpen, children }: LightboxProps 10 | ) { 11 | return ( 12 | <> 13 | {match({ isOpen: isOpen }) 14 | .with({ isOpen: true }, () => ( 15 |
21 |
22 |
23 | 31 |
32 | {children} 33 |
34 |
35 | )) 36 | .otherwise(() => null)} 37 | 38 | ); 39 | } 40 | -------------------------------------------------------------------------------- /src/app/is-refetching.tsx: -------------------------------------------------------------------------------- 1 | import { tw } from "~lib/utils/tw"; 2 | 3 | export default function IsRefetching() { 4 | return ( 5 |
6 |
13 |
20 |
27 |
34 |
41 |
48 |
49 | ); 50 | } 51 | -------------------------------------------------------------------------------- /cypress/e2e/homepage.cy.ts: -------------------------------------------------------------------------------- 1 | describe("Homepage", () => { 2 | it("Should display homepage and test it", () => { 3 | cy.visit("http://localhost:3000/"); 4 | 5 | // test heading 6 | cy.get("h1").should("be.visible").contains("Cari Mahasiswa"); 7 | 8 | // test description 9 | cy.get(`[data-cy="description"]`) 10 | .should("be.visible") 11 | .contains( 12 | "Sebuah Website untuk mencari data Mahasiswa dari berbagai perguruan tinggi di Indonesia." 13 | ); 14 | 15 | // test switch theme button 16 | cy.get(`[data-cy="switch-theme"]`).should("be.visible").click("center"); 17 | 18 | cy.wait(1000); 19 | 20 | // test back to top button 21 | cy.scrollTo("center") 22 | .get(`[data-cy="back-to-top"]`) 23 | .should("be.visible") 24 | .click("center"); 25 | 26 | // test form input if user's input is only alphabet or number 27 | cy.get("form") 28 | .should("be.visible") 29 | .type("Andi", { delay: 100 }) 30 | .submit() 31 | .get(`[data-cy="card"]`) 32 | .should("be.visible"); 33 | 34 | cy.get("input").clear(); 35 | 36 | // test form input if user's input is other than alphabet or number 37 | cy.get("form") 38 | .type("!@#$%^", { delay: 100 }) 39 | .get(`[data-cy="error-message"]`) 40 | .should("be.visible") 41 | .contains("The characters must be alphabet, or number!"); 42 | }); 43 | }); 44 | -------------------------------------------------------------------------------- /cypress/support/commands.ts: -------------------------------------------------------------------------------- 1 | /// 2 | // *********************************************** 3 | // This example commands.ts shows you how to 4 | // create various custom commands and overwrite 5 | // existing commands. 6 | // 7 | // For more comprehensive examples of custom 8 | // commands please read more here: 9 | // https://on.cypress.io/custom-commands 10 | // *********************************************** 11 | // 12 | // 13 | // -- This is a parent command -- 14 | // Cypress.Commands.add('login', (email, password) => { ... }) 15 | // 16 | // 17 | // -- This is a child command -- 18 | // Cypress.Commands.add('drag', { prevSubject: 'element'}, (subject, options) => { ... }) 19 | // 20 | // 21 | // -- This is a dual command -- 22 | // Cypress.Commands.add('dismiss', { prevSubject: 'optional'}, (subject, options) => { ... }) 23 | // 24 | // 25 | // -- This will overwrite an existing command -- 26 | // Cypress.Commands.overwrite('visit', (originalFn, url, options) => { ... }) 27 | // 28 | // declare global { 29 | // namespace Cypress { 30 | // interface Chainable { 31 | // login(email: string, password: string): Chainable 32 | // drag(subject: string, options?: Partial): Chainable 33 | // dismiss(subject: string, options?: Partial): Chainable 34 | // visit(originalFn: CommandOriginalFn, url: string, options: Partial): Chainable 35 | // } 36 | // } 37 | // } -------------------------------------------------------------------------------- /src/app/perguruan-tinggi/[slug]/client.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { useAtom } from "jotai"; 4 | import Lightbox from "~components/lightbox"; 5 | import Image from "~components/ui/image"; 6 | import { isOpenLogoAtom } from "~store"; 7 | import { LogoDetailProps } from "~types"; 8 | 9 | export function SeeLogoDetail({ src, alt }: LogoDetailProps) { 10 | const [isOpenLogo, setIsOpenLogo] = useAtom(isOpenLogoAtom); 11 | 12 | return ( 13 |
14 | 30 |
31 | ); 32 | } 33 | 34 | export function DetailLogo({ src, alt }: LogoDetailProps) { 35 | const [isOpenLogo, setIsOpenLogo] = useAtom(isOpenLogoAtom); 36 | 37 | return ( 38 | 39 |
40 | {alt} 49 |
50 |
51 | ); 52 | } 53 | -------------------------------------------------------------------------------- /src/components/switch-theme.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { MoonIcon, SunIcon } from "lucide-react"; 4 | import { useTheme } from "next-themes"; 5 | import * as React from "react"; 6 | 7 | import { Button } from "./ui/button"; 8 | import { 9 | DropdownMenu, 10 | DropdownMenuContent, 11 | DropdownMenuItem, 12 | DropdownMenuTrigger, 13 | } from "./ui/dropdown-menu"; 14 | 15 | export default function SwitchTheme() { 16 | const { setTheme } = useTheme(); 17 | 18 | return ( 19 | 20 | 21 | 31 | 32 | 33 | setTheme("light")} 35 | className="font-medium" 36 | > 37 | Light 38 | 39 | setTheme("dark")} 41 | className="font-medium" 42 | > 43 | Dark 44 | 45 | setTheme("system")} 47 | className="font-medium" 48 | > 49 | System 50 | 51 | 52 | 53 | ); 54 | } 55 | -------------------------------------------------------------------------------- /src/app/page.tsx: -------------------------------------------------------------------------------- 1 | import { Metadata } from "next"; 2 | import { Heading, Paragraph } from "~components/ui/typography"; 3 | 4 | import Client from "./client"; 5 | 6 | export const metadata: Metadata = { 7 | title: "Cari Mahasiswa", 8 | description: 9 | "Sebuah Website untuk mencari data Mahasiswa dari berbagai perguruan tinggi", 10 | openGraph: { 11 | type: "website", 12 | url: "https://crmhs.ekel.dev", 13 | title: "Cari Mahasiswa", 14 | description: 15 | "Sebuah Website untuk mencari data Mahasiswa dari berbagai perguruan tinggi", 16 | images: [ 17 | { 18 | url: "/banner.png", 19 | alt: "OG Image", 20 | }, 21 | ], 22 | siteName: "crmhs.ekel.dev", 23 | }, 24 | twitter: { 25 | title: "Cari Mahasiswa", 26 | description: 27 | "Sebuah Website untuk mencari data Mahasiswa dari berbagai perguruan tinggi", 28 | site: "https://crmhs.ekel.dev", 29 | card: "summary_large_image", 30 | }, 31 | metadataBase: new URL("https://crmhs.ekel.dev"), 32 | }; 33 | 34 | export default function HomePage() { 35 | return ( 36 | <> 37 |
38 |
39 |
40 | Cari Mahasiswa 41 |
42 | 43 | Sebuah Website untuk mencari data Mahasiswa dari berbagai perguruan 44 | tinggi di Indonesia. 45 | 46 | 47 |
48 |
49 | 50 | ); 51 | } 52 | -------------------------------------------------------------------------------- /src/app/loading-client.tsx: -------------------------------------------------------------------------------- 1 | import { tw } from "~lib/utils/tw"; 2 | 3 | export default function LoadingClient() { 4 | return ( 5 |
6 |
13 |
14 |
21 |
28 |
35 |
42 |
49 |
56 |
57 |
58 | ); 59 | } 60 | -------------------------------------------------------------------------------- /src/features/index.ts: -------------------------------------------------------------------------------- 1 | import { env } from "~env.mjs"; 2 | import { configuredOfetch } from "~lib/utils/configured-ofetch"; 3 | import { 4 | DetailMahasiswaProps, 5 | DetailPerguruanTinggiProps, 6 | MahasiswaProps, 7 | ProdiPerguruanTinggiProps, 8 | } from "~types"; 9 | 10 | const { NEXT_PUBLIC_API_URL } = env; 11 | 12 | export async function getMahasiswa(value: string): Promise { 13 | try { 14 | const response: MahasiswaProps = await configuredOfetch( 15 | `${NEXT_PUBLIC_API_URL}/hit_mhs/${value ? value : "Yuuki"}` 16 | ); 17 | 18 | return response; 19 | } catch (err: any) { 20 | throw new Error("Failed to fetch data!"); 21 | } 22 | } 23 | 24 | export async function getUniversityDetail( 25 | slug: string 26 | ): Promise { 27 | try { 28 | const response: DetailPerguruanTinggiProps = await configuredOfetch( 29 | `${NEXT_PUBLIC_API_URL}/v2/detail_pt/${slug}` 30 | ); 31 | 32 | return response; 33 | } catch (err: any) { 34 | throw new Error("Failed to fetch data!"); 35 | } 36 | } 37 | 38 | export async function getStudentDetail( 39 | slug: string 40 | ): Promise { 41 | try { 42 | const response: DetailMahasiswaProps = await configuredOfetch( 43 | `${NEXT_PUBLIC_API_URL}/detail_mhs/${slug}` 44 | ); 45 | 46 | return response; 47 | } catch (err: any) { 48 | throw new Error("Failed to fetch data!"); 49 | } 50 | } 51 | 52 | export async function getUniversityListProdi( 53 | slug: string 54 | ): Promise { 55 | try { 56 | const response: ProdiPerguruanTinggiProps[] = await configuredOfetch( 57 | `${NEXT_PUBLIC_API_URL}/v2/detail_pt_prodi/${slug}` 58 | ); 59 | 60 | return response; 61 | } catch (err: any) { 62 | throw new Error("Failed to fetch data!"); 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /src/server/index.ts: -------------------------------------------------------------------------------- 1 | import { initTRPC } from "@trpc/server"; 2 | import { z } from "zod"; 3 | import { getMahasiswa } from "~features"; 4 | import { BaseMahasiswaProps, MahasiswaProps } from "~types"; 5 | 6 | const t = initTRPC.create(); 7 | 8 | export const router = t.router; 9 | export const publicProcedure = t.procedure; 10 | 11 | export const appRouter = router({ 12 | get: publicProcedure 13 | .input(z.object({ value: z.string() })) 14 | .output( 15 | z.object({ 16 | total: z.number(), 17 | mahasiswa: z 18 | .object({ 19 | nama: z.string().min(1), 20 | pt: z.string().min(1), 21 | prodi: z.string().min(1), 22 | hash: z.string().min(1), 23 | }) 24 | .array(), 25 | }) 26 | ) 27 | .query(async ({ input }) => { 28 | const data = (await getMahasiswa(input.value)) as MahasiswaProps; 29 | 30 | const mahasiswa = data.mahasiswa 31 | .map((item) => { 32 | // replace "PT :" and "prodi:" string 33 | const replaceStr = item.text 34 | .replace(/PT :|prodi: /gi, "") 35 | .split(", "); 36 | 37 | // replace "/data_mahasiswa" string 38 | const hash = item["website-link"].replace( 39 | /data_mahasiswa|[^a-z0-9]/gi, 40 | "" 41 | ); 42 | 43 | return { 44 | data: replaceStr, 45 | hash: hash, 46 | }; 47 | }) 48 | .map((item) => { 49 | return { 50 | nama: item.data[0], 51 | pt: item.data[1], 52 | prodi: item.data[2], 53 | hash: item.hash, 54 | }; 55 | }) as BaseMahasiswaProps[]; 56 | 57 | return { 58 | total: data.mahasiswa.length as number, 59 | mahasiswa: mahasiswa, 60 | }; 61 | }), 62 | }); 63 | 64 | export type AppRouter = typeof appRouter; 65 | -------------------------------------------------------------------------------- /src/app/globals.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | 5 | body { 6 | scrollbar-width: thin; 7 | } 8 | 9 | ::-webkit-scrollbar { 10 | width: 10px; 11 | } 12 | 13 | ::-webkit-scrollbar-thumb { 14 | @apply bg-red-500 dark:bg-blue-500; 15 | } 16 | 17 | ::-webkit-scrollbar-track { 18 | @apply bg-neutral-100 dark:bg-neutral-700; 19 | } 20 | 21 | @layer base { 22 | :root { 23 | --background: 0 0% 100%; 24 | --foreground: 0 0% 3.9%; 25 | 26 | --card: 0 0% 100%; 27 | --card-foreground: 0 0% 3.9%; 28 | 29 | --popover: 0 0% 100%; 30 | --popover-foreground: 0 0% 3.9%; 31 | 32 | --primary: 0 0% 9%; 33 | --primary-foreground: 0 0% 98%; 34 | 35 | --secondary: 0 0% 96.1%; 36 | --secondary-foreground: 0 0% 9%; 37 | 38 | --muted: 0 0% 96.1%; 39 | --muted-foreground: 0 0% 45.1%; 40 | 41 | --accent: 0 0% 96.1%; 42 | --accent-foreground: 0 0% 9%; 43 | 44 | --destructive: 0 84.2% 60.2%; 45 | --destructive-foreground: 0 0% 98%; 46 | 47 | --border: 0 0% 89.8%; 48 | --input: 0 0% 89.8%; 49 | --ring: 0 0% 3.9%; 50 | 51 | --radius: 0.5rem; 52 | } 53 | 54 | .dark { 55 | --background: 0 0% 3.9%; 56 | --foreground: 0 0% 98%; 57 | 58 | --card: 0 0% 3.9%; 59 | --card-foreground: 0 0% 98%; 60 | 61 | --popover: 0 0% 3.9%; 62 | --popover-foreground: 0 0% 98%; 63 | 64 | --primary: 0 0% 98%; 65 | --primary-foreground: 0 0% 9%; 66 | 67 | --secondary: 0 0% 14.9%; 68 | --secondary-foreground: 0 0% 98%; 69 | 70 | --muted: 0 0% 14.9%; 71 | --muted-foreground: 0 0% 63.9%; 72 | 73 | --accent: 0 0% 14.9%; 74 | --accent-foreground: 0 0% 98%; 75 | 76 | --destructive: 0 62.8% 30.6%; 77 | --destructive-foreground: 0 0% 98%; 78 | 79 | --border: 0 0% 14.9%; 80 | --input: 0 0% 14.9%; 81 | --ring: 0 0% 83.1%; 82 | } 83 | } 84 | 85 | @layer base { 86 | * { 87 | @apply border-border; 88 | } 89 | 90 | body { 91 | @apply bg-background text-foreground; 92 | } 93 | } 94 | -------------------------------------------------------------------------------- /src/components/ui/button.tsx: -------------------------------------------------------------------------------- 1 | import { Slot } from "@radix-ui/react-slot"; 2 | import { type VariantProps, cva } from "class-variance-authority"; 3 | import * as React from "react"; 4 | import { tw } from "~lib/utils/tw"; 5 | 6 | const buttonVariants = cva( 7 | "inline-flex items-center justify-center 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", 8 | { 9 | variants: { 10 | variant: { 11 | default: 12 | "bg-primary text-primary-foreground shadow hover:bg-primary/90", 13 | destructive: 14 | "bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90", 15 | outline: 16 | "border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground", 17 | secondary: 18 | "bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80", 19 | ghost: "hover:bg-accent hover:text-accent-foreground", 20 | link: "text-primary underline-offset-4 hover:underline", 21 | }, 22 | size: { 23 | default: "h-9 px-4 py-2", 24 | sm: "h-8 rounded-md px-3 text-xs", 25 | lg: "h-10 rounded-md px-8", 26 | icon: "h-9 w-9", 27 | }, 28 | }, 29 | defaultVariants: { 30 | variant: "default", 31 | size: "default", 32 | }, 33 | } 34 | ); 35 | 36 | export interface ButtonProps 37 | extends React.ButtonHTMLAttributes, 38 | VariantProps { 39 | asChild?: boolean; 40 | } 41 | 42 | const Button = React.forwardRef( 43 | ({ className, variant, size, asChild = false, ...props }, ref) => { 44 | const Comp = asChild ? Slot : "button"; 45 | return ( 46 | 51 | ); 52 | } 53 | ); 54 | Button.displayName = "Button"; 55 | 56 | export { Button, buttonVariants }; 57 | -------------------------------------------------------------------------------- /src/app/layout.tsx: -------------------------------------------------------------------------------- 1 | import { Analytics } from "@vercel/analytics/react"; 2 | import { Metadata } from "next"; 3 | import dynamic from "next/dynamic"; 4 | import { Inter } from "next/font/google"; 5 | import { tw } from "~lib/utils/tw"; 6 | import { ChildrenProps } from "~types"; 7 | 8 | import Footer from "../components/footer"; 9 | import "./globals.css"; 10 | import Wrapper from "./wrapper"; 11 | 12 | const BackToTop = dynamic(() => import("~components/back-to-top")); 13 | const SwitchTheme = dynamic(() => import("~components/switch-theme"), { 14 | loading: () => { 15 | return ( 16 |
22 | ); 23 | }, 24 | ssr: false, 25 | }); 26 | 27 | const inter = Inter({ subsets: ["latin"] }); 28 | 29 | export const metadata: Metadata = { 30 | title: "Cari Mahasiswa", 31 | description: 32 | "Sebuah Website untuk mencari data Mahasiswa dari berbagai perguruan tinggi", 33 | openGraph: { 34 | type: "website", 35 | url: "https://crmhs.ekel.dev", 36 | title: "Cari Mahasiswa", 37 | description: 38 | "Sebuah Website untuk mencari data Mahasiswa dari berbagai perguruan tinggi", 39 | images: [ 40 | { 41 | url: "/banner.png", 42 | alt: "OG Image", 43 | }, 44 | ], 45 | siteName: "crmhs.ekel.dev", 46 | }, 47 | twitter: { 48 | title: "Cari Mahasiswa", 49 | description: 50 | "Sebuah Website untuk mencari data Mahasiswa dari berbagai perguruan tinggi", 51 | site: "https://crmhs.ekel.dev", 52 | card: "summary_large_image", 53 | }, 54 | metadataBase: new URL("https://crmhs.ekel.dev"), 55 | }; 56 | 57 | export default function RootLayout({ children }: ChildrenProps) { 58 | return ( 59 | 60 | 61 | 62 | 63 | {children} 64 |