├── .github └── workflows │ └── ci.yaml ├── .gitignore ├── LICENSE.md ├── Makefile ├── README.md ├── docker-compose.dev.yaml ├── docker-compose.yaml ├── docs ├── example_api_response.json ├── spots.png └── topdown.png └── frontend ├── .env.template ├── .eslintrc.json ├── .gitignore ├── Dockerfile ├── README.md ├── app ├── api │ └── open-spots │ │ └── route.ts ├── favicon.ico ├── fonts │ ├── GeistMonoVF.woff │ └── GeistVF.woff ├── globals.css ├── layout.tsx └── page.tsx ├── components.json ├── components ├── controls │ ├── building-drawer │ │ ├── building-drawer.module.css │ │ ├── building-drawer.tsx │ │ └── index.ts │ ├── index.ts │ └── map │ │ ├── index.ts │ │ └── map.tsx └── ui │ ├── accordion.tsx │ ├── alert.tsx │ ├── hover-card.tsx │ ├── index.ts │ ├── loading │ ├── index.ts │ └── loading.tsx │ └── scroll-area.tsx ├── lib ├── helpers │ ├── index.ts │ └── map │ │ ├── index.ts │ │ └── map.helpers.ts ├── index.ts ├── services │ ├── index.ts │ └── map-data │ │ ├── functions │ │ ├── index.ts │ │ └── map-data.ts │ │ ├── index.ts │ │ └── map-data.ts ├── types │ ├── index.ts │ └── map.types.ts └── utils.ts ├── next.config.mjs ├── package-lock.json ├── package.json ├── postcss.config.mjs ├── public ├── images │ └── github.png └── logo.png ├── tailwind.config.ts ├── tsconfig.json └── vercel.json /.github/workflows/ci.yaml: -------------------------------------------------------------------------------- 1 | name: Push Docker Images 2 | 3 | on: 4 | push: 5 | branches: ["master"] 6 | 7 | jobs: 8 | build: 9 | runs-on: ubuntu-latest 10 | steps: 11 | - name: Checkout 12 | uses: actions/checkout@v4 13 | 14 | - name: Login to Docker Hub 15 | uses: docker/login-action@v3 16 | with: 17 | username: ${{ secrets.DOCKERHUB_USERNAME }} 18 | password: ${{ secrets.DOCKERHUB_TOKEN }} 19 | 20 | - name: Set up Docker Buildx 21 | uses: docker/setup-buildx-action@v3 22 | 23 | - name: Build and push frontend image 24 | uses: docker/build-push-action@v5 25 | with: 26 | context: ./frontend 27 | file: frontend/Dockerfile 28 | push: true 29 | tags: ${{ secrets.DOCKERHUB_USERNAME }}/${{ secrets.DOCKERHUB_REPO }}:${{ github.sha }} 30 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | docker-compose.dev.test.yaml -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 Akshar Barot 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. -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | build: 2 | docker-compose build 3 | 4 | build-force: 5 | docker-compose build --no-cache 6 | 7 | up: 8 | docker-compose -f docker-compose.yaml up -d 9 | 10 | up-dev: 11 | docker-compose -f docker-compose.yaml -f docker-compose.dev.yaml up -d 12 | 13 | down: 14 | docker-compose down 15 | 16 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

2 | Open Spots Logo 3 |

4 | 5 | 6 | # Open Spots 7 | 8 | **Open Spots** is a fork of [Spots](https://github.com/notAkki/spots) that is designed to help organizations deliver real-time building availability data to staff, employees, customers, or students. Developers can create an API to deliver their own data to the Open Spots platform. This data can be visualized on an interactive map, and display available spots (rooms with time slots). 9 | 10 | ![Open Spots Side View](/docs/spots.png) 11 | ![Open Spots Top Down View](/docs/topdown.png) 12 | 13 | ## Features 14 | 15 | - Displays open spots across an organization's building or campus. 16 | - Sorts spots based on proximity to the user’s current location. 17 | - Interactive map to visualize building locations. 18 | - List view of spots with real-time status updates (based on the organization's data). 19 | 20 | ## Tech Stack 21 | 22 | ### Frontend 23 | 24 | - **Next.js**: Handles server-side rendering and provides a robust React-based framework for building the frontend UI. 25 | - **Mapbox GL**: Provides the interactive map to display building locations. 26 | - **Tailwind CSS**: Used for styling the UI components with utility-first CSS for responsive and consistent design. 27 | - **Geolocation API**: Retrieves the user’s current location to sort spots by proximity. 28 | 29 | ### Backend 30 | 31 | - **Flask**: A lightweight Python web framework to handle API requests and logic for retrieving and processing spot data. 32 | - **Requests**: A Python library used in Flask to fetch spot data from external APIs. 33 | - **Haversine Formula**: Implemented in the backend to calculate the distance between the user and spot locations based on coordinates. 34 | 35 | 36 | ## API 37 | 38 | There are a few different variables that can be set in the `/frontend/.env` file to customize Open Spots. 39 | 40 | - `API_URL`: The URL of the API (with the endpoint) that will be used to fetch spot data. **Required** 41 | - `NEXT_PUBLIC_MAPBOX_ACCESS_TOKEN`: The Mapbox access token to be used for the map. **Required** 42 | - `NEXT_PUBLIC_MAPBOX_STYLE_URL`: The Mapbox style URL to be used for the map. **Required** 43 | - `NEXT_PUBLIC_STARTING_CENTER_COORDS`: The starting center coordinates to be used for the map. 44 | - `NEXT_PUBLIC_STARTING_ZOOM`: The starting zoom level to be used for the map. 45 | - `NEXT_PUBLIC_STARTING_PITCH`: The starting pitch level to be used for the map. 46 | - `NEXT_PUBLIC_SITE_TITLE`: The title of the site to be used in the header. 47 | - `NEXT_PUBLIC_SITE_DESCRIPTION`: The description of the site to be used in the header. 48 | 49 | Your API should return a JSON object with the following fields: 50 | 51 | - `logo`: The URL of the logo to be used in the header. **Optional** 52 | - `data`: An array of building data. 53 | 54 | The building data has the following fields: 55 | 56 | - `building`: The name of the building. 57 | - `building_code`: The code of the building. 58 | - `building_status`: The status of the building. 59 | - `rooms`: A dictionary of room numbers to room data. 60 | - `coords`: The coordinates of the building. 61 | - `distance`: The distance to the building from the user's current location. 62 | - `distance_unit`: The unit of distance ("mi" or "km") 63 | 64 | The room data has the following fields: 65 | 66 | - `roomNumber`: The number of the room. 67 | - `slots`: An array of time slots for the room. 68 | 69 | The slots data has the following fields: 70 | 71 | - `StartTime`: The start time of the time slot. 72 | - `EndTime`: The end time of the time slot. 73 | - `Status`: The status of the time slot. 74 | 75 | An example API response can be found in `docs/example_api_response.json`. 76 | 77 | The Open Spots API sends a POST (if the user has geolocation enabled) or GET request to the API URL set in the `/frontend/.env` file. 78 | 79 | The post request body is a JSON object with the following fields: 80 | 81 | - `lat`: The latitude of the user's current location. 82 | - `lng`: The longitude of the user's current location. 83 | 84 | ## Local Setup 85 | 86 | 1. Install Docker and Docker Compose. 87 | 2. Run `make up` to start Open Spots. 88 | -------------------------------------------------------------------------------- /docker-compose.dev.yaml: -------------------------------------------------------------------------------- 1 | services: 2 | frontend: 3 | command: npm run dev 4 | volumes: 5 | - ./frontend/app:/app/app 6 | - ./frontend/components:/app/components 7 | - ./frontend/lib:/app/lib 8 | - ./frontend/public:/app/public 9 | - ./frontend/package.json:/app/package.json 10 | -------------------------------------------------------------------------------- /docker-compose.yaml: -------------------------------------------------------------------------------- 1 | services: 2 | frontend: 3 | container_name: open-spots 4 | restart: unless-stopped 5 | env_file: ./frontend/.env 6 | image: jpyles0524/open-spots 7 | build: 8 | context: ./frontend 9 | dockerfile: Dockerfile 10 | command: npm run start 11 | ports: 12 | - 3000:3000 -------------------------------------------------------------------------------- /docs/example_api_response.json: -------------------------------------------------------------------------------- 1 | { 2 | "logo": "**Optional** Your Logo URL", 3 | "data": [ 4 | { 5 | "building": "Your Building Name", 6 | "building_code": "Your Building Code", 7 | "building_status": "available", 8 | "coords": [0, 0], 9 | "distance": 0, 10 | "distance_unit": "mi", 11 | "labels": [ 12 | { 13 | "label": "Your Label", 14 | "color": "**Optional** Your Color (HEX VALUE)" 15 | } 16 | ], 17 | "rooms": [ 18 | { 19 | "roomNumber": "Your Room Number", 20 | "slots": [ 21 | { 22 | "EndTime": "Your End Time", 23 | "StartTime": "Your Start Time", 24 | "Status": "Your Status" 25 | } 26 | ] 27 | } 28 | ] 29 | } 30 | ] 31 | } 32 | -------------------------------------------------------------------------------- /docs/spots.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jaypyles/open-spots/8026c9e00b0cc404da8e4f02cca8b64c340038c7/docs/spots.png -------------------------------------------------------------------------------- /docs/topdown.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jaypyles/open-spots/8026c9e00b0cc404da8e4f02cca8b64c340038c7/docs/topdown.png -------------------------------------------------------------------------------- /frontend/.env.template: -------------------------------------------------------------------------------- 1 | NEXT_PUBLIC_MAPBOX_ACCESS_TOKEN= 2 | NEXT_PUBLIC_MAPBOX_STYLE_URL=mapbox://styles/jpyles0524/cm2qof1hq00cp01qi2cd69r16 3 | NEXT_PUBLIC_STARTING_CENTER_COORDS=, 4 | NEXT_PUBLIC_STARTING_ZOOM= 5 | NEXT_PUBLIC_STARTING_PITCH= 6 | API_URL=/ # this will be the endpoint to receive data from your backend 7 | NEXT_PUBLIC_SITE_TITLE= 8 | NEXT_PUBLIC_SITE_DESCRIPTION= 9 | -------------------------------------------------------------------------------- /frontend/.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "next/core-web-vitals" 3 | } 4 | -------------------------------------------------------------------------------- /frontend/.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 | .env 39 | 40 | -------------------------------------------------------------------------------- /frontend/Dockerfile: -------------------------------------------------------------------------------- 1 | # Stage 1: Dependencies 2 | FROM node:21 AS depsbuilder 3 | 4 | # Set the working directory in the container 5 | WORKDIR /app 6 | 7 | # Copy only the package files to install dependencies 8 | COPY package.json package-lock.json /app/ 9 | 10 | # Install the dependencies 11 | RUN npm install 12 | 13 | # Stage 2: Build 14 | FROM node:21 15 | 16 | # Set the working directory in the container 17 | WORKDIR /app 18 | 19 | # Copy application files and configuration 20 | COPY app /app/app 21 | COPY components /app/components 22 | COPY lib /app/lib 23 | COPY public /app/public 24 | COPY tsconfig.json /app 25 | COPY components.json /app 26 | COPY next.config.mjs /app 27 | COPY tailwind.config.ts /app 28 | COPY postcss.config.mjs /app 29 | COPY package.json package-lock.json /app/ 30 | 31 | # Copy installed node_modules from the depsbuilder stage 32 | COPY --from=depsbuilder /app/node_modules /app/node_modules 33 | 34 | # Build the application 35 | RUN npm run build 36 | 37 | # Expose the port that your app runs on 38 | EXPOSE 3000 39 | -------------------------------------------------------------------------------- /frontend/README.md: -------------------------------------------------------------------------------- 1 | # Spots Frontend 2 | 3 | ## Local Development 4 | 5 | 1. Install Docker and Docker Compose. 6 | 2. Run `make build up-dev` to start the frontend for local development. 7 | 8 | ### Environment Variables 9 | 10 | Copy these into a `.env` file in the frontend directory using `.env.example` as a template. 11 | 12 | ```bash 13 | cp .env.example .env 14 | ``` -------------------------------------------------------------------------------- /frontend/app/api/open-spots/route.ts: -------------------------------------------------------------------------------- 1 | import { NextResponse } from "next/server"; 2 | import { type MapData } from "@/lib"; 3 | 4 | export async function POST(req: Request) { 5 | try { 6 | const { lat, lng } = await req.json(); 7 | 8 | const response = await fetch(process.env.API_URL as string, { 9 | method: "POST", 10 | headers: { 11 | "Content-Type": "application/json", 12 | }, 13 | body: JSON.stringify({ lat, lng }), 14 | }); 15 | 16 | if (!response.ok) { 17 | return NextResponse.json( 18 | { error: "Failed to fetch data" }, 19 | { status: 500 } 20 | ); 21 | } 22 | 23 | const data: MapData[] = await response.json(); 24 | return NextResponse.json(data); 25 | } catch (error) { 26 | console.error("Error in route:", error); 27 | return NextResponse.json( 28 | { error: "Failed to process request" }, 29 | { status: 500 } 30 | ); 31 | } 32 | } 33 | 34 | export async function GET() { 35 | try { 36 | const response = await fetch(process.env.API_URL as string, { 37 | method: "GET", 38 | cache: "no-cache", 39 | }); 40 | 41 | if (!response.ok) { 42 | return NextResponse.json( 43 | { error: "Failed to fetch data" }, 44 | { status: 500 } 45 | ); 46 | } 47 | 48 | const data: MapData[] = await response.json(); 49 | return NextResponse.json(data); 50 | } catch (error) { 51 | console.error("Error in GET route:", error); 52 | return NextResponse.json( 53 | { error: "Failed to process request" }, 54 | { status: 500 } 55 | ); 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /frontend/app/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jaypyles/open-spots/8026c9e00b0cc404da8e4f02cca8b64c340038c7/frontend/app/favicon.ico -------------------------------------------------------------------------------- /frontend/app/fonts/GeistMonoVF.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jaypyles/open-spots/8026c9e00b0cc404da8e4f02cca8b64c340038c7/frontend/app/fonts/GeistMonoVF.woff -------------------------------------------------------------------------------- /frontend/app/fonts/GeistVF.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jaypyles/open-spots/8026c9e00b0cc404da8e4f02cca8b64c340038c7/frontend/app/fonts/GeistVF.woff -------------------------------------------------------------------------------- /frontend/app/globals.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | 5 | :root { 6 | --background: #0a0a0f; 7 | --foreground: #f4f4f5; 8 | --popup-background: #18181bbe; 9 | } 10 | 11 | @media (prefers-color-scheme: dark) { 12 | :root { 13 | --background: #18181b; 14 | --foreground: #ededed; 15 | } 16 | } 17 | 18 | body { 19 | color: var(--foreground); 20 | background: var(--background); 21 | font-family: Arial, Helvetica, sans-serif; 22 | } 23 | 24 | @layer utilities { 25 | .text-balance { 26 | text-wrap: balance; 27 | } 28 | } 29 | 30 | @layer base { 31 | :root { 32 | --radius: 0.5rem; 33 | } 34 | } 35 | 36 | #map-container { 37 | height: 100%; 38 | width: 100%; 39 | background-color: lightgrey; 40 | border-radius: 20px; 41 | } 42 | 43 | .marker-popup { 44 | background-color: var(--popup-background); 45 | border-radius: 10px; 46 | padding: 0.25rem; 47 | } 48 | -------------------------------------------------------------------------------- /frontend/app/layout.tsx: -------------------------------------------------------------------------------- 1 | import type { Metadata } from "next"; 2 | import localFont from "next/font/local"; 3 | import "./globals.css"; 4 | 5 | const geistSans = localFont({ 6 | src: "./fonts/GeistVF.woff", 7 | variable: "--font-geist-sans", 8 | weight: "100 900", 9 | }); 10 | const geistMono = localFont({ 11 | src: "./fonts/GeistMonoVF.woff", 12 | variable: "--font-geist-mono", 13 | weight: "100 900", 14 | }); 15 | 16 | export const metadata: Metadata = { 17 | title: process.env.NEXT_PUBLIC_SITE_TITLE || "Open Spots", 18 | description: 19 | process.env.NEXT_PUBLIC_SITE_DESCRIPTION || 20 | "Track open rooms between buildings.", 21 | }; 22 | 23 | export default function RootLayout({ 24 | children, 25 | }: Readonly<{ 26 | children: React.ReactNode; 27 | }>) { 28 | return ( 29 | 30 | 33 | {children} 34 | 35 | 36 | ); 37 | } 38 | -------------------------------------------------------------------------------- /frontend/app/page.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | import { useEffect, useState } from "react"; 3 | import { ScrollArea } from "@/components/ui/scroll-area"; 4 | import Image from "next/image"; 5 | import { Map as MapComponent } from "@/components/controls/map/map"; 6 | import { Loading } from "@/components/ui"; 7 | import { BuildingDrawer } from "@/components/controls"; 8 | import { APIResponse, type MapData } from "@/lib"; 9 | import { mapDataService } from "@/lib/services"; 10 | 11 | export default function OpenSpots() { 12 | const [logo, setLogo] = useState(null); 13 | const [data, setData] = useState([]); 14 | const [activeBuilding, setActiveBuilding] = useState(null); 15 | const [userPos, setUserPos] = useState<[number, number] | null>(null); 16 | const [loading, setLoading] = useState(true); 17 | const startingCenterCoords = process.env.NEXT_PUBLIC_STARTING_CENTER_COORDS 18 | ? (process.env.NEXT_PUBLIC_STARTING_CENTER_COORDS.split(",").map( 19 | Number 20 | ) as [number, number]) 21 | : undefined; 22 | const startingZoom = process.env.NEXT_PUBLIC_STARTING_ZOOM 23 | ? Number(process.env.NEXT_PUBLIC_STARTING_ZOOM) 24 | : undefined; 25 | const startingPitch = process.env.NEXT_PUBLIC_STARTING_PITCH 26 | ? Number(process.env.NEXT_PUBLIC_STARTING_PITCH) 27 | : undefined; 28 | 29 | const handleMarkerClick = (building: string) => { 30 | setActiveBuilding(building); 31 | }; 32 | 33 | useEffect(() => { 34 | const fetchLocationAndData = async () => { 35 | setLoading(true); 36 | 37 | if (navigator.geolocation) { 38 | navigator.geolocation.getCurrentPosition( 39 | async (position) => { 40 | const { latitude, longitude } = position.coords; 41 | setUserPos([latitude, longitude]); 42 | 43 | const data: APIResponse = await mapDataService.sendUserLocation( 44 | latitude, 45 | longitude 46 | ); 47 | setData(data.data); 48 | setLogo(data.logo || null); 49 | setLoading(false); 50 | }, 51 | async (error) => { 52 | console.error("Error fetching location here:", error); 53 | const defaultData: APIResponse = 54 | await mapDataService.sendDefaultLocationData(); 55 | setData(defaultData.data); 56 | setLogo(defaultData.logo || null); 57 | setLoading(false); 58 | } 59 | ); 60 | } else { 61 | console.error("Geolocation is not supported by this browser."); 62 | const defaultData: APIResponse = 63 | await mapDataService.sendDefaultLocationData(); 64 | setData(defaultData.data); 65 | setLogo(defaultData.logo || null); 66 | setLoading(false); 67 | } 68 | }; 69 | 70 | fetchLocationAndData(); 71 | }, []); 72 | 73 | if (loading) { 74 | return ; 75 | } 76 | 77 | return ( 78 |
79 |
80 |
81 | Logo 82 | {logo && Logo} 83 |
84 | 85 |
86 |
87 |
88 | Logo 89 |
90 | 95 |
96 |
97 |
98 |
99 | Github 105 | Github 106 |
107 |
108 |
109 | 117 |
118 |
119 | ); 120 | } 121 | -------------------------------------------------------------------------------- /frontend/components.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://ui.shadcn.com/schema.json", 3 | "style": "new-york", 4 | "rsc": true, 5 | "tsx": true, 6 | "tailwind": { 7 | "config": "tailwind.config.ts", 8 | "css": "app/globals.css", 9 | "baseColor": "zinc", 10 | "cssVariables": false, 11 | "prefix": "" 12 | }, 13 | "aliases": { 14 | "components": "@/components", 15 | "utils": "@/lib/utils", 16 | "ui": "@/components/ui", 17 | "lib": "@/lib", 18 | "hooks": "@/hooks" 19 | } 20 | } -------------------------------------------------------------------------------- /frontend/components/controls/building-drawer/building-drawer.module.css: -------------------------------------------------------------------------------- 1 | .available { 2 | background-color: #34d399; 3 | } 4 | 5 | .opening-soon { 6 | background-color: #34d39991; 7 | } 8 | 9 | .unavailable { 10 | background-color: #f87171; 11 | } 12 | 13 | .closing-soon { 14 | background-color: #f8717191; 15 | } 16 | 17 | .upcoming { 18 | background-color: #fbbd23; 19 | } 20 | 21 | .available-label { 22 | background-color: rgb(22 101 52 / 0.3); 23 | color: rgb(134 239 172 / 0.9); 24 | } 25 | 26 | .unavailable-label { 27 | background-color: rgb(185 28 28 / 0.3); 28 | color: rgb(252 165 165 / 0.9); 29 | } 30 | 31 | .upcoming-label { 32 | background-color: rgb(146 64 14 / 0.3); 33 | color: rgb(252 211 77 / 0.9); 34 | } 35 | 36 | .distance { 37 | font-size: 0.825rem; 38 | color: lightgray; 39 | } 40 | 41 | .tags { 42 | display: flex; 43 | gap: 0.5rem; 44 | } 45 | 46 | .tag { 47 | padding: 0.25rem; 48 | line-height: 1; 49 | border-radius: 0.25rem; 50 | font-size: 0.75rem; 51 | } 52 | -------------------------------------------------------------------------------- /frontend/components/controls/building-drawer/building-drawer.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | import { 3 | Accordion, 4 | AccordionContent, 5 | AccordionItem, 6 | AccordionTrigger, 7 | } from "@/components/ui/accordion"; 8 | import { 9 | formatTime, 10 | roundDistanceToHundreds, 11 | sliceString, 12 | type MapData, 13 | } from "@/lib"; 14 | import { clsx } from "clsx"; 15 | import classes from "./building-drawer.module.css"; 16 | 17 | export const statusLabel = (status: string) => { 18 | return ( 19 |
26 | {status} 27 |
28 | ); 29 | }; 30 | 31 | function statusIndicator(status: string) { 32 | return ( 33 |
40 | ); 41 | } 42 | 43 | export type BuildingDrawerProps = { 44 | data: MapData[]; 45 | activeBuilding: string | null; 46 | setActiveBuilding: (building: string) => void; 47 | }; 48 | 49 | export const BuildingDrawer = ({ 50 | data, 51 | activeBuilding, 52 | setActiveBuilding, 53 | }: BuildingDrawerProps) => { 54 | const getBuildingType = (building: MapData) => { 55 | if (!building.labels) { 56 | return null; 57 | } 58 | 59 | return building.labels.map((label) => ( 60 |
65 | {label.label} 66 |
67 | )); 68 | }; 69 | 70 | return ( 71 |
72 | setActiveBuilding(val)} 78 | > 79 | {data.map((building) => ( 80 | 86 | 87 |
88 |
89 |
90 |
91 | {building.building} 92 |
93 |
94 | {getBuildingType(building)} 95 |
96 |
97 |
98 | {roundDistanceToHundreds(building.distance)}{" "} 99 | {building.distance_unit} 100 |
101 |
102 |
{statusLabel(building.building_status)}
103 |
104 |
105 | 106 | {building.rooms && 107 | Object.entries(building.rooms).map(([roomNumber, room]) => { 108 | return ( 109 |
113 |
114 |
115 |
116 | {sliceString( 117 | `${building.building_code} ${room.roomNumber}`, 118 | 60 119 | )} 120 |
121 |
122 |
123 | {room.slots && room.slots.length > 0 ? ( 124 | statusIndicator(room.slots[0].Status) 125 | ) : ( 126 |
127 | No slots available 128 |
129 | )} 130 |
131 |
132 |
    133 | {room.slots && room.slots.length > 0 ? ( 134 | room.slots.map((slot, index) => ( 135 |
  • 136 | {formatTime(slot.StartTime)} -{" "} 137 | {formatTime(slot.EndTime)} 138 |
  • 139 | )) 140 | ) : ( 141 |
  • No slots available
  • 142 | )} 143 |
144 |
145 | ); 146 | })} 147 |
148 |
149 | ))} 150 |
151 |
152 | ); 153 | }; 154 | -------------------------------------------------------------------------------- /frontend/components/controls/building-drawer/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./building-drawer"; 2 | -------------------------------------------------------------------------------- /frontend/components/controls/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./map"; 2 | export * from "./building-drawer"; 3 | -------------------------------------------------------------------------------- /frontend/components/controls/map/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./map"; 2 | -------------------------------------------------------------------------------- /frontend/components/controls/map/map.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | import React from "react"; 3 | import { useRef, useEffect, useState } from "react"; 4 | import mapboxgl from "mapbox-gl"; 5 | import "mapbox-gl/dist/mapbox-gl.css"; 6 | import { MapData } from "@/lib/types"; 7 | import { createMarkerFromMapData } from "@/lib/helpers"; 8 | 9 | export type MapProps = { 10 | data: MapData[]; 11 | handleMarkerClick: (building: string) => void; 12 | userPos: [number, number] | null; 13 | startingCenterCoords?: [number, number]; 14 | startingZoom?: number; 15 | startingPitch?: number; 16 | }; 17 | 18 | export const Map = ({ 19 | data, 20 | handleMarkerClick, 21 | userPos, 22 | startingCenterCoords = [0, 0], 23 | startingZoom = 100, 24 | startingPitch = 100, 25 | }: MapProps) => { 26 | const mapRef = useRef(null); 27 | const mapContainerRef = useRef(null); 28 | const [center, setCenter] = useState<[number, number]>(startingCenterCoords); 29 | const [zoom, setZoom] = useState(startingZoom); 30 | const [pitch, setPitch] = useState(startingPitch); 31 | const mapboxToken = process.env.NEXT_PUBLIC_MAPBOX_ACCESS_TOKEN; 32 | const MAPBOX_STYLE_URL = process.env.NEXT_PUBLIC_MAPBOX_STYLE_URL; 33 | // const MAPBOX_STYLE_URL = "mapbox://styles/michaelgathara/cm2r2794200gj01phd50hflad"; 34 | 35 | useEffect(() => { 36 | if (!mapboxToken) { 37 | throw new Error("Mapbox token is not defined"); 38 | } 39 | 40 | mapboxgl.accessToken = mapboxToken; 41 | mapRef.current = new mapboxgl.Map({ 42 | style: MAPBOX_STYLE_URL, 43 | container: mapContainerRef.current as HTMLElement, 44 | center: center, 45 | zoom: zoom, 46 | pitch: pitch, 47 | }); 48 | 49 | mapRef.current.on("move", () => { 50 | if (mapRef.current) { 51 | const mapCenter = mapRef.current.getCenter(); 52 | const mapZoom = mapRef.current.getZoom(); 53 | const mapPitch = mapRef.current.getPitch(); 54 | 55 | setCenter([mapCenter.lng, mapCenter.lat]); 56 | setZoom(mapZoom); 57 | setPitch(mapPitch); 58 | } 59 | }); 60 | 61 | data.map((data) => { 62 | const buildingMarker = createMarkerFromMapData(data, handleMarkerClick); 63 | 64 | if (mapRef.current && data.coords) { 65 | const marker = new mapboxgl.Marker(buildingMarker) 66 | .setLngLat([data.coords[0], data.coords[1]]) 67 | .addTo(mapRef.current); 68 | 69 | // Create a popup div for the building name 70 | const popup = document.createElement("div"); 71 | popup.className = "marker-popup"; 72 | popup.innerText = data.building; 73 | 74 | // Add hover effect for building markers 75 | marker.getElement().addEventListener("mouseenter", () => { 76 | marker.getElement().style.cursor = "pointer"; 77 | const markerElement = marker.getElement(); 78 | const markerRect = markerElement.getBoundingClientRect(); 79 | const mapContainerRect = 80 | mapContainerRef.current?.getBoundingClientRect(); 81 | 82 | markerElement.style.cursor = "pointer"; 83 | 84 | if (mapContainerRect) { 85 | popup.style.zIndex = "1000"; 86 | popup.style.left = `${ 87 | markerRect.left - mapContainerRect.left - 20 88 | }px`; 89 | popup.style.top = `${ 90 | markerRect.top - mapContainerRect.top - popup.offsetHeight - 30 91 | }px`; 92 | } 93 | popup.style.display = "block"; 94 | popup.style.position = "absolute"; 95 | popup.style.zIndex = "1000"; 96 | mapContainerRef.current?.appendChild(popup); 97 | }); 98 | 99 | marker.getElement().addEventListener("mouseleave", () => { 100 | marker.getElement().style.cursor = ""; 101 | popup.style.display = "none"; 102 | if (popup.parentNode) { 103 | popup.parentNode.removeChild(popup); 104 | } 105 | }); 106 | } 107 | }); 108 | 109 | if (userPos) { 110 | const userMarker = document.createElement("div"); 111 | userMarker.className = 112 | "h-3 w-3 border-[1.5px] border-zinc-50 rounded-full bg-blue-400 shadow-[0px_0px_4px_2px_rgba(14,165,233,1)]"; 113 | const userMarkerInstance = new mapboxgl.Marker(userMarker) 114 | .setLngLat([userPos[1], userPos[0]]) 115 | .addTo(mapRef.current); 116 | 117 | userMarkerInstance.getElement().addEventListener("mouseenter", () => { 118 | userMarkerInstance.getElement().style.cursor = "pointer"; 119 | }); 120 | userMarkerInstance.getElement().addEventListener("mouseleave", () => { 121 | userMarkerInstance.getElement().style.cursor = ""; 122 | }); 123 | } 124 | 125 | return () => { 126 | if (mapRef.current) { 127 | mapRef.current.remove(); 128 | } 129 | }; 130 | }, []); 131 | 132 | return ( 133 |
134 |
135 |
136 |
137 |
138 |
139 | unavailable 140 |
141 |
142 |
143 |
144 |
145 | closing soon 146 |
147 |
148 |
149 |
150 |
151 | opening soon 152 |
153 |
154 |
155 |
156 |
157 | open now 158 |
159 |
160 |
161 |
162 | ); 163 | }; 164 | -------------------------------------------------------------------------------- /frontend/components/ui/accordion.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import * as React from "react"; 4 | import * as AccordionPrimitive from "@radix-ui/react-accordion"; 5 | import { ChevronDownIcon } from "@radix-ui/react-icons"; 6 | 7 | import { cn } from "@/lib/utils"; 8 | 9 | const Accordion = AccordionPrimitive.Root; 10 | 11 | const AccordionItem = React.forwardRef< 12 | React.ElementRef, 13 | React.ComponentPropsWithoutRef 14 | >(({ className, ...props }, ref) => ( 15 | 20 | )); 21 | AccordionItem.displayName = "AccordionItem"; 22 | 23 | const AccordionTrigger = React.forwardRef< 24 | React.ElementRef, 25 | React.ComponentPropsWithoutRef 26 | >(({ className, children, ...props }, ref) => ( 27 | 28 | svg]:rotate-180", 32 | className 33 | )} 34 | {...props} 35 | > 36 | {children} 37 | 38 | 39 | 40 | )); 41 | AccordionTrigger.displayName = AccordionPrimitive.Trigger.displayName; 42 | 43 | const AccordionContent = React.forwardRef< 44 | React.ElementRef, 45 | React.ComponentPropsWithoutRef 46 | >(({ className, children, ...props }, ref) => ( 47 | 52 |
{children}
53 |
54 | )); 55 | AccordionContent.displayName = AccordionPrimitive.Content.displayName; 56 | 57 | export { Accordion, AccordionItem, AccordionTrigger, AccordionContent }; 58 | -------------------------------------------------------------------------------- /frontend/components/ui/alert.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import { cva, type VariantProps } from "class-variance-authority"; 3 | 4 | import { cn } from "@/lib/utils"; 5 | 6 | const alertVariants = cva( 7 | "relative w-full rounded-lg border border-zinc-700 px-3 py-2 text-sm [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-zinc-950 [&>svg~*]:pl-7 dark:border-zinc-800 dark:[&>svg]:text-zinc-50", 8 | { 9 | variants: { 10 | variant: { 11 | default: 12 | "bg-zinc-950/0 text-zinc-50 dark:bg-zinc-950 dark:text-zinc-50", 13 | destructive: 14 | "border-red-500/50 text-red-500 dark:border-red-500 [&>svg]:text-red-500 dark:border-red-900/50 dark:text-red-900 dark:dark:border-red-900 dark:[&>svg]:text-red-900", 15 | }, 16 | }, 17 | defaultVariants: { 18 | variant: "default", 19 | }, 20 | } 21 | ); 22 | 23 | const Alert = React.forwardRef< 24 | HTMLDivElement, 25 | React.HTMLAttributes & VariantProps 26 | >(({ className, variant, ...props }, ref) => ( 27 |
33 | )); 34 | Alert.displayName = "Alert"; 35 | 36 | const AlertTitle = React.forwardRef< 37 | HTMLParagraphElement, 38 | React.HTMLAttributes 39 | >(({ className, ...props }, ref) => ( 40 |
45 | )); 46 | AlertTitle.displayName = "AlertTitle"; 47 | 48 | const AlertDescription = React.forwardRef< 49 | HTMLParagraphElement, 50 | React.HTMLAttributes 51 | >(({ className, ...props }, ref) => ( 52 |
57 | )); 58 | AlertDescription.displayName = "AlertDescription"; 59 | 60 | export { Alert, AlertTitle, AlertDescription }; 61 | -------------------------------------------------------------------------------- /frontend/components/ui/hover-card.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import * as React from "react"; 4 | import * as HoverCardPrimitive from "@radix-ui/react-hover-card"; 5 | 6 | import { cn } from "@/lib/utils"; 7 | 8 | const HoverCard = HoverCardPrimitive.Root; 9 | 10 | const HoverCardTrigger = HoverCardPrimitive.Trigger; 11 | 12 | const HoverCardContent = React.forwardRef< 13 | React.ElementRef, 14 | React.ComponentPropsWithoutRef 15 | >(({ className, align = "center", sideOffset = 4, ...props }, ref) => ( 16 | 26 | )); 27 | HoverCardContent.displayName = HoverCardPrimitive.Content.displayName; 28 | 29 | export { HoverCard, HoverCardTrigger, HoverCardContent }; 30 | -------------------------------------------------------------------------------- /frontend/components/ui/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./loading"; 2 | -------------------------------------------------------------------------------- /frontend/components/ui/loading/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./loading"; 2 | -------------------------------------------------------------------------------- /frontend/components/ui/loading/loading.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | export const Loading = () => { 4 | return ( 5 |
6 | 15 | 21 | 22 |

Loading...

23 |
24 | ); 25 | }; 26 | -------------------------------------------------------------------------------- /frontend/components/ui/scroll-area.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import * as React from "react"; 4 | import * as ScrollAreaPrimitive from "@radix-ui/react-scroll-area"; 5 | 6 | import { cn } from "@/lib/utils"; 7 | 8 | const ScrollArea = React.forwardRef< 9 | React.ElementRef, 10 | React.ComponentPropsWithoutRef 11 | >(({ className, children, ...props }, ref) => ( 12 | 17 | 18 | {children} 19 | 20 | 21 | 22 | 23 | )); 24 | ScrollArea.displayName = ScrollAreaPrimitive.Root.displayName; 25 | 26 | const ScrollBar = React.forwardRef< 27 | React.ElementRef, 28 | React.ComponentPropsWithoutRef< 29 | typeof ScrollAreaPrimitive.ScrollAreaScrollbar 30 | > 31 | >(({ className, orientation = "vertical", ...props }, ref) => ( 32 | 45 | 46 | 47 | )); 48 | ScrollBar.displayName = ScrollAreaPrimitive.ScrollAreaScrollbar.displayName; 49 | 50 | export { ScrollArea, ScrollBar }; 51 | -------------------------------------------------------------------------------- /frontend/lib/helpers/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./map"; 2 | -------------------------------------------------------------------------------- /frontend/lib/helpers/map/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./map.helpers"; 2 | -------------------------------------------------------------------------------- /frontend/lib/helpers/map/map.helpers.ts: -------------------------------------------------------------------------------- 1 | import { MapData } from "@/lib/types"; 2 | 3 | export const STATUS_TO_COLOR = { 4 | available: 5 | "h-2 w-2 rounded-full bg-green-400 shadow-[0px_0px_4px_2px_rgba(34,197,94,0.7)]", 6 | unavailable: 7 | "h-2 w-2 rounded-full bg-red-400 shadow-[0px_0px_4px_2px_rgba(239,68,68,0.9)]", 8 | upcoming: 9 | "h-2 w-2 rounded-full bg-amber-400 shadow-[0px_0px_4px_2px_rgba(245,158,11,0.9)]", 10 | }; 11 | 12 | export const GLOW_EFFECT_FROM_STATUS = { 13 | available: "0 0 10px rgba(34,197,94,0.7)", 14 | unavailable: "0 0 10px rgba(239,68,68,0.9)", 15 | upcoming: "0 0 10px rgba(245,158,11,0.9)", 16 | }; 17 | 18 | export const DRAWER_STATUS_TO_COLOR = { 19 | available: "bg-green-800/20 text-green-300/90", 20 | unavailable: "bg-red-700/20 text-red-300/80", 21 | upcoming: "bg-amber-800/20 text-amber-300/90", 22 | }; 23 | 24 | export const createMarkerFromMapData = ( 25 | data: MapData, 26 | handleMarkerClick: (building: string) => void 27 | ) => { 28 | const el = document.createElement("div"); 29 | el.className = 30 | STATUS_TO_COLOR[data.building_status as keyof typeof STATUS_TO_COLOR]; 31 | 32 | el.style.width = "17.5px"; 33 | el.style.height = "17.5px"; 34 | el.style.boxShadow = 35 | GLOW_EFFECT_FROM_STATUS[ 36 | data.building_status as keyof typeof GLOW_EFFECT_FROM_STATUS 37 | ]; 38 | 39 | el.addEventListener("click", () => { 40 | const accordionItem = document.getElementById(data.building_code); 41 | 42 | setTimeout(() => { 43 | if (accordionItem) { 44 | accordionItem.scrollIntoView({ 45 | behavior: "smooth", 46 | block: "start", 47 | }); 48 | } 49 | }, 300); 50 | 51 | handleMarkerClick(data.building_code); 52 | }); 53 | 54 | return el; 55 | }; 56 | -------------------------------------------------------------------------------- /frontend/lib/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./helpers"; 2 | export * from "./types"; 3 | export * from "./utils"; 4 | -------------------------------------------------------------------------------- /frontend/lib/services/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./map-data"; 2 | -------------------------------------------------------------------------------- /frontend/lib/services/map-data/functions/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./map-data"; 2 | -------------------------------------------------------------------------------- /frontend/lib/services/map-data/functions/map-data.ts: -------------------------------------------------------------------------------- 1 | export const sendUserLocation = async (latitude: number, longitude: number) => { 2 | try { 3 | const res = await fetch(`/api/open-spots`, { 4 | method: "POST", 5 | headers: { 6 | "Content-Type": "application/json", 7 | }, 8 | body: JSON.stringify({ 9 | lat: latitude, 10 | lng: longitude, 11 | }), 12 | }); 13 | 14 | const data = await res.json(); 15 | return data; 16 | } catch (error) { 17 | console.error("Failed to fetch data from backend:", error); 18 | } 19 | }; 20 | 21 | export const sendDefaultLocationData = async () => { 22 | const response = await fetch("/api/open-spots"); 23 | const data = await response.json(); 24 | return data; 25 | }; 26 | -------------------------------------------------------------------------------- /frontend/lib/services/map-data/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./map-data"; 2 | -------------------------------------------------------------------------------- /frontend/lib/services/map-data/map-data.ts: -------------------------------------------------------------------------------- 1 | import { sendUserLocation, sendDefaultLocationData } from "./functions"; 2 | 3 | export const mapDataService = { 4 | sendUserLocation, 5 | sendDefaultLocationData, 6 | }; 7 | -------------------------------------------------------------------------------- /frontend/lib/types/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./map.types"; -------------------------------------------------------------------------------- /frontend/lib/types/map.types.ts: -------------------------------------------------------------------------------- 1 | export type APIResponse = { 2 | data: MapData[]; 3 | logo?: string; 4 | }; 5 | 6 | export type MapData = { 7 | building: string; 8 | building_code: string; 9 | building_status: string; 10 | rooms: Record; 11 | coords: [number, number]; 12 | distance: number; 13 | distance_unit: string; 14 | labels?: Label[]; 15 | logo?: string; 16 | }; 17 | 18 | export type Label = { 19 | label: string; 20 | color?: string; 21 | }; 22 | 23 | export type Slot = { 24 | StartTime: string; 25 | EndTime: string; 26 | Status: string; 27 | }; 28 | 29 | export type Room = { 30 | roomNumber: string; 31 | slots: Slot[]; 32 | }; 33 | -------------------------------------------------------------------------------- /frontend/lib/utils.ts: -------------------------------------------------------------------------------- 1 | import { clsx, type ClassValue } from "clsx"; 2 | import { twMerge } from "tailwind-merge"; 3 | 4 | export function cn(...inputs: ClassValue[]) { 5 | return twMerge(clsx(inputs)); 6 | } 7 | 8 | export function formatTime(timeString: string) { 9 | const options = { 10 | hour: "numeric" as "numeric", 11 | minute: "numeric" as "numeric", 12 | hour12: true, 13 | }; 14 | 15 | const time = new Date(`1970-01-01T${timeString}`); 16 | if (isNaN(time.getTime())) { 17 | console.error("Invalid time string:", timeString); 18 | return "Invalid time"; 19 | } 20 | 21 | return new Intl.DateTimeFormat("en-US", options).format(time); 22 | } 23 | 24 | export function roundDistanceToHundreds(distance: number) { 25 | return Math.round(distance * 100) / 100; 26 | } 27 | 28 | export const sliceString = (str: string, maxLength: number) => { 29 | return str.length > maxLength ? str.slice(0, maxLength) + "..." : str; 30 | }; 31 | -------------------------------------------------------------------------------- /frontend/next.config.mjs: -------------------------------------------------------------------------------- 1 | /** @type {import('next').NextConfig} */ 2 | const nextConfig = {}; 3 | 4 | export default nextConfig; 5 | -------------------------------------------------------------------------------- /frontend/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "frontend", 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 | }, 11 | "dependencies": { 12 | "@radix-ui/react-accordion": "^1.2.0", 13 | "@radix-ui/react-hover-card": "^1.1.1", 14 | "@radix-ui/react-icons": "^1.3.0", 15 | "@radix-ui/react-scroll-area": "^1.1.0", 16 | "class-variance-authority": "^0.7.0", 17 | "clsx": "^2.1.1", 18 | "lucide-react": "^0.445.0", 19 | "mapbox-gl": "^3.7.0", 20 | "next": "14.2.13", 21 | "react": "^18", 22 | "react-dom": "^18", 23 | "tailwind-merge": "^2.5.2", 24 | "tailwindcss-animate": "^1.0.7" 25 | }, 26 | "devDependencies": { 27 | "@types/node": "^20", 28 | "@types/react": "^18", 29 | "@types/react-dom": "^18", 30 | "eslint": "^8", 31 | "eslint-config-next": "14.2.13", 32 | "postcss": "^8", 33 | "tailwindcss": "^3.4.1", 34 | "typescript": "^5" 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /frontend/postcss.config.mjs: -------------------------------------------------------------------------------- 1 | /** @type {import('postcss-load-config').Config} */ 2 | const config = { 3 | plugins: { 4 | tailwindcss: {}, 5 | }, 6 | }; 7 | 8 | export default config; 9 | -------------------------------------------------------------------------------- /frontend/public/images/github.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jaypyles/open-spots/8026c9e00b0cc404da8e4f02cca8b64c340038c7/frontend/public/images/github.png -------------------------------------------------------------------------------- /frontend/public/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jaypyles/open-spots/8026c9e00b0cc404da8e4f02cca8b64c340038c7/frontend/public/logo.png -------------------------------------------------------------------------------- /frontend/tailwind.config.ts: -------------------------------------------------------------------------------- 1 | import type { Config } from "tailwindcss"; 2 | 3 | const config: Config = { 4 | darkMode: ["class"], 5 | content: [ 6 | "./pages/**/*.{js,ts,jsx,tsx,mdx}", 7 | "./components/**/*.{js,ts,jsx,tsx,mdx}", 8 | "./app/**/*.{js,ts,jsx,tsx,mdx}", 9 | ], 10 | safelist: [ 11 | "w-2", 12 | "h-2", 13 | "bg-red-400", 14 | "bg-green-400", 15 | "bg-amber-400", 16 | "shadow-xl", 17 | "shadow-cyan-500/50", 18 | ], 19 | theme: { 20 | extend: { 21 | colors: { 22 | background: "var(--background)", 23 | foreground: "var(--foreground)", 24 | }, 25 | borderRadius: { 26 | lg: "var(--radius)", 27 | md: "calc(var(--radius) - 2px)", 28 | sm: "calc(var(--radius) - 4px)", 29 | }, 30 | keyframes: { 31 | "accordion-down": { 32 | from: { 33 | height: "0", 34 | }, 35 | to: { 36 | height: "var(--radix-accordion-content-height)", 37 | }, 38 | }, 39 | "accordion-up": { 40 | from: { 41 | height: "var(--radix-accordion-content-height)", 42 | }, 43 | to: { 44 | height: "0", 45 | }, 46 | }, 47 | }, 48 | animation: { 49 | "accordion-down": "accordion-down 0.2s ease-out", 50 | "accordion-up": "accordion-up 0.2s ease-out", 51 | }, 52 | }, 53 | }, 54 | plugins: [require("tailwindcss-animate")], 55 | }; 56 | export default config; 57 | -------------------------------------------------------------------------------- /frontend/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "lib": ["dom", "dom.iterable", "esnext"], 4 | "allowJs": true, 5 | "skipLibCheck": true, 6 | "strict": true, 7 | "noEmit": true, 8 | "esModuleInterop": true, 9 | "module": "esnext", 10 | "moduleResolution": "bundler", 11 | "resolveJsonModule": true, 12 | "isolatedModules": true, 13 | "jsx": "preserve", 14 | "incremental": true, 15 | "plugins": [ 16 | { 17 | "name": "next" 18 | } 19 | ], 20 | "paths": { 21 | "@/*": ["./*"] 22 | } 23 | }, 24 | "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], 25 | "exclude": ["node_modules"] 26 | } 27 | -------------------------------------------------------------------------------- /frontend/vercel.json: -------------------------------------------------------------------------------- 1 | { 2 | "rewrites": [{ "source": "/(.*)", "destination": "/" }] 3 | } 4 | --------------------------------------------------------------------------------