diff --git a/.env b/.env new file mode 100644 index 0000000..d832c14 --- /dev/null +++ b/.env @@ -0,0 +1 @@ +SCAVENGER_HUNT_DISABLED=true \ No newline at end of file diff --git a/.prettierrc b/.prettierrc index 2ec0843..866e69f 100644 --- a/.prettierrc +++ b/.prettierrc @@ -4,5 +4,6 @@ "singleQuote": true, "jsxSingleQuote": false, "endOfLine": "lf", - "singleAttributePerLine": true + "singleAttributePerLine": true, + "plugins": ["prettier-plugin-tailwindcss"] } diff --git a/.vscode/settings.json b/.vscode/settings.json index 69bcd71..f0b5460 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -4,5 +4,14 @@ "editor.formatOnPaste": true, "[javascript]": { "editor.defaultFormatter": "esbenp.prettier-vscode" + }, + "typescript.preferences.importModuleSpecifier": "non-relative", + "files.associations": { + "*.css": "tailwindcss" + }, + "editor.quickSuggestions": { + "other": "on", + "comments": "off", + "strings": "on" } } diff --git a/bun.lockb b/bun.lockb index dea1ba4..533657d 100755 Binary files a/bun.lockb and b/bun.lockb differ diff --git a/components.json b/components.json new file mode 100644 index 0000000..d6f002e --- /dev/null +++ b/components.json @@ -0,0 +1,20 @@ +{ + "$schema": "https://ui.shadcn.com/schema.json", + "style": "default", + "rsc": false, + "tsx": true, + "tailwind": { + "config": "tailwind.config.js", + "css": "styles/globals.css", + "baseColor": "neutral", + "cssVariables": true, + "prefix": "" + }, + "aliases": { + "components": "@/components", + "utils": "@/lib/utils", + "ui": "@/components/ui", + "lib": "@/lib", + "hooks": "@/hooks" + } +} diff --git a/components/TourMap.js b/components/TourMap.js deleted file mode 100644 index d6aee58..0000000 --- a/components/TourMap.js +++ /dev/null @@ -1,303 +0,0 @@ -import React, { useState, createRef, useCallback, useMemo } from 'react' -import Container from 'react-bootstrap/Container' -import Modal from 'react-bootstrap/Modal' -import Button from 'react-bootstrap/Button' -import ListGroup from 'react-bootstrap/ListGroup' -import ReactMarkdown from 'react-markdown' -import L from 'leaflet' -import { - MapContainer, - TileLayer, - ZoomControl, - Marker, - Popup, -} from 'react-leaflet' -import PropTypes from 'prop-types' - -import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' -import { faPhotoVideo } from '@fortawesome/free-solid-svg-icons' - -import styles from '../styles/TourMap.module.css' -import Link from 'next/link' -import Image from 'next/image' - -const ICONS = { - gastro: '/pin_green.svg', - hochschule: '/pin_indigo.svg', - chill: '/pin_orange.svg', - sehenswuerdig: '/pin_red.svg', - nuetzlich: '/pin_gray.svg', -} - -const HEADINGS = { - gastro: 'Bars & Cafés', - hochschule: 'Hochschule', - chill: 'Chillen', - sehenswuerdig: 'Sehenswürdigkeiten', - nuetzlich: 'Nützliches', -} - -// use less padding on small devices -// because they do not have enough space to fit the popup plus the padding -const PADDING = window.matchMedia('(max-width: 768px)').matches ? 10 : 50 - -// use custom link implementation to set rel="noopener" -const COMPONENTS = { - a(props) { - return ( - - ) - }, -} - -const icons = {} -for (const category of Object.keys(ICONS)) { - icons[category] = L.icon({ - iconUrl: ICONS[category], - iconSize: [20, 35], - iconAnchor: [10, 35], - popupAnchor: [0, -35], - }) -} - -function getGoogleMapsLink(lat, lon) { - return `https://www.google.com/maps/search/?api=1&query=${lat},${lon}` -} -function getAppleMapsLink(name, lat, lon) { - return `https://maps.apple.com/?q=${encodeURIComponent(name)}&ll=${lat},${lon}` -} -function getOSMLink(lat, lon) { - return `https://www.openstreetmap.org/index.html?lat=${lat}&lon=${lon}&mlat=${lat}&mlon=${lon}&zoom=19&layers=M` -} - -export default function TourMap({ center, data }) { - const [showModal, setShowModal] = useState(true) - - const markerRefs = useMemo(() => data.map(() => createRef()), [data]) - - const categorizedData = useMemo(() => { - return data - .map((elem) => elem.category) - .filter((v, i, a) => a.indexOf(v) === i) - .map((category) => { - const items = data - .map((elem, idx) => ({ - id: idx, - ...elem, - })) - .filter((elem) => elem.category === category) - .sort((a, b) => a.title.localeCompare(b.title)) - - return { category, items } - }) - }, [data]) - - const openElem = useCallback( - (idx) => { - const marker = markerRefs[idx].current - if (marker) { - marker.openPopup() - } - }, - [markerRefs] - ) - - return ( - <> - setShowModal(false)} - > - - Virtuelle Stadt- und Campusführung - - - -

- Klicke auf eine Markierung, um mehr über diesen Ort zu erfahren. - Viele der Orte haben auch ein kurzes Video. -

-

- Die virtuelle Stadt- und Campusführung ist ein Projekt der{' '} - - Fachschaft Informatik - {' '} - in Kooperation mit{' '} - - Neuland Ingolstadt - - . -

- - - - - - - -
- - Studierendenvertretung TH Ingolstadt - - -
- - - -
- - {categorizedData.map(({ category, items }) => ( - - - - {HEADINGS[category]} - - {items - .filter((elem) => !elem.hide) - .map((elem) => ( - openElem(elem.id)} - > - {elem.title} - {elem.video && ( -
- -
- )} -
- ))} -
- ))} -
- - - - - - - {data.map((elem, idx) => ( - - -

{elem.title}

-
- {navigator.language.startsWith('de') ? ( - - {elem.description_de} - - ) : ( - - {elem.description_en} - - )} -
- {elem.video && ( - - )} -

- Öffnen in{' '} - - OpenStreetMap - - {', '} - - Google Maps - - {', '} - - Apple Maps - -

-
-
- ))} -
-
- - ) -} -TourMap.propTypes = { - center: PropTypes.array, - data: PropTypes.array, -} diff --git a/components/guide/glossaryAccordion.tsx b/components/guide/glossaryAccordion.tsx new file mode 100644 index 0000000..589e45c --- /dev/null +++ b/components/guide/glossaryAccordion.tsx @@ -0,0 +1,44 @@ +import { + Accordion, + AccordionContent, + AccordionItem, + AccordionTrigger, +} from '@/components/ui/accordion' +import { COMPONENTS } from '@/components/ui/markdownComponents' +import ReactMarkdown from 'react-markdown' + +export interface GlossaryItem { + title: string + content: string +} + +interface GlossaryAccordionProps { + glossary: GlossaryItem[] +} + +export default function GlossaryAccordion({ + glossary, +}: GlossaryAccordionProps) { + return ( + + {glossary.map((item) => ( + + +
{item.title}
+
+ + + {item.content} + + +
+ ))} +
+ ) +} diff --git a/components/guide/guideAccordion.tsx b/components/guide/guideAccordion.tsx new file mode 100644 index 0000000..32542dc --- /dev/null +++ b/components/guide/guideAccordion.tsx @@ -0,0 +1,92 @@ +import { + Accordion, + AccordionContent, + AccordionItem, + AccordionTrigger, +} from '@/components/ui/accordion' +import { Button } from '@/components/ui/button' +import { Card, CardContent } from '@/components/ui/card' +import { COMPONENTS } from '@/components/ui/markdownComponents' +import { ExternalLink } from 'lucide-react' +import Link from 'next/link' +import ReactMarkdown from 'react-markdown' + +export interface GuideSection { + category: string + title: string + content: GuideItem[] +} + +export interface GuideItem { + title: string + content: string + link: string + linktitle?: string +} + +interface GuideAccordionProps { + guide: GuideSection[] +} + +export default function GuideAccordion({ guide }: GuideAccordionProps) { + return ( + + {guide.map((item) => ( + + +
+ {item.title} +
+
+ + + + + {item.content.map((content) => ( + + +
+ {content.title} +
+
+ + + {content.content} + + {content.link.length > 0 && ( + + + + )} + +
+ ))} +
+
+
+
+
+ ))} +
+ ) +} diff --git a/components/guide/guideTabs.tsx b/components/guide/guideTabs.tsx new file mode 100644 index 0000000..8f50743 --- /dev/null +++ b/components/guide/guideTabs.tsx @@ -0,0 +1,57 @@ +import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs' +import { useRouter } from 'next/router' +import { useEffect } from 'react' + +export const TABS = [ + { + title: 'Studium', + link: '/guide/studies', + }, + { + title: 'Leben', + link: '/guide/life', + }, + { + title: 'Campus', + link: '/guide/campus', + }, + { + title: 'Glossar', + link: '/guide/glossary', + }, +] + +export default function GuideTabs() { + const router = useRouter() + + const { pathname } = router + + // prefetch all tabs + useEffect(() => { + TABS.forEach((tab) => { + router.prefetch(tab.link) + }) + }, [router]) + + return ( + + + {TABS.map((link) => ( + { + e.preventDefault() + router.replace(link.link) + }} + > + {link.title} + + ))} + + + ) +} diff --git a/components/map/attributionControl.tsx b/components/map/attributionControl.tsx new file mode 100644 index 0000000..7ee9dae --- /dev/null +++ b/components/map/attributionControl.tsx @@ -0,0 +1,111 @@ +import { useMap } from 'react-map-gl/maplibre' +import 'maplibre-gl/dist/maplibre-gl.css' +import React, { useCallback, useEffect, useRef, useState } from 'react' +import { CircleChevronRight, Info } from 'lucide-react' +import { cn } from '@/lib/utils' + +interface AttributionControlProps { + attribution: string | React.ReactNode +} + +export function AttributionControl({ attribution }: AttributionControlProps) { + const [collapsed, setCollapsed] = useState(false) + const map = useMap().current + + const attributionRef = useRef(null) + const [targetWidth, setTargetWidth] = useState(0) + + useEffect(() => { + function getTargetWidth() { + if (!attributionRef.current) { + return + } + + attributionRef.current.style.width = 'auto' + const width = attributionRef.current.offsetWidth + 6 + attributionRef.current.style.width = width + 'px' + + attributionRef.current.style.transition = + 'width 0.3s ease-in-out, opacity 0.3s ease-in-out' + + setTargetWidth(width) + } + + getTargetWidth() + }, [attributionRef]) + + const toggleCollapsed = useCallback( + (value?: boolean) => { + if (!attributionRef.current) { + return + } + + const collapsedTemp = value ?? !collapsed + + if (value !== undefined) { + setCollapsed(value) + } else { + setCollapsed((prev) => !prev) + } + + if (!collapsedTemp) { + attributionRef.current.style.width = targetWidth + 'px' + attributionRef.current.style.opacity = '1' + } else { + attributionRef.current.style.width = '0px' + attributionRef.current.style.opacity = '0' + } + }, + [collapsed, targetWidth] + ) + + useEffect(() => { + if (!map) { + return + } + + map.on('move', () => { + toggleCollapsed(true) + }) + }, [collapsed, map, toggleCollapsed]) + + useEffect(() => { + // close attribution after 5 seconds + const timeout = setTimeout(() => { + toggleCollapsed(true) + }, 5000) + + return () => clearTimeout(timeout) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []) + + if (!map) { + return null + } + + return ( +
+
+
+
{attribution}
+
+ + { + e.preventDefault() + toggleCollapsed() + }} + className="cursor-pointer text-black dark:text-white" + > +
+ {collapsed ? : } +
+
+
+
+ ) +} diff --git a/components/map/styleControl.tsx b/components/map/styleControl.tsx new file mode 100644 index 0000000..76ef6ce --- /dev/null +++ b/components/map/styleControl.tsx @@ -0,0 +1,51 @@ +import { MapStyle } from '@/components/tour/tourMap' +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from '@/components/ui/dropdown-menu' +import { cn } from '@/lib/utils' +import { Map } from 'lucide-react' + +interface MapStyleControlProps { + className?: string + onStyleChange: (style: MapStyle) => void +} + +export default function MapStyleControl({ + className, + onStyleChange, +}: MapStyleControlProps) { + return ( +
+ + +
+ + + +
+
+ + Kartenstil + + onStyleChange('bright')}> + Standard + + onStyleChange('light')}> + Modern Hell + + onStyleChange('dark')}> + Modern Dunkel + + +
+
+ ) +} diff --git a/components/tour/tourDetails.tsx b/components/tour/tourDetails.tsx new file mode 100644 index 0000000..da33509 --- /dev/null +++ b/components/tour/tourDetails.tsx @@ -0,0 +1,155 @@ +import { Button } from '@/components/ui/button' +import { + Dialog, + DialogContent, + DialogFooter, + DialogTitle, +} from '@/components/ui/dialog' +import { + Drawer, + DrawerContent, + DrawerHeader, + DrawerTitle, +} from '@/components/ui/drawer' +import { COMPONENTS } from '@/components/ui/markdownComponents' +import { ScrollArea } from '@/components/ui/scroll-area' +import { useTourDetails } from '@/lib/hooks/tourDetails' +import { cn } from '@/lib/utils' +import { TourData } from '@/pages/tour/[city]' +import { useMediaQuery } from 'usehooks-ts' +import Link from 'next/link' +import { useCallback, useMemo } from 'react' +import { SiApple, SiGooglemaps, SiOpenstreetmap } from 'react-icons/si' +import ReactMarkdown from 'react-markdown' + +interface TourDialogProps { + popup: TourData | undefined + setPopup: (popup: TourData | undefined) => void +} + +export default function TourDetails({ popup, setPopup }: TourDialogProps) { + const open = useMemo(() => !!popup, [popup]) + const isDesktop = useMediaQuery('(min-width: 1024px)') + const onOpenChange = useCallback( + (open: boolean) => { + if (!open) setPopup(undefined) + }, + [setPopup] + ) + + const { osmLink, googleMapsLink, appleMapsLink, description } = + useTourDetails(popup!) + + const body = useMemo(() => { + if (!popup) return null + + return ( +
+ {description && ( +
+ {description} +
+ )} + {popup.video && ( + + )} +
+ ) + }, [description, popup]) + + if (!popup) return null + + if (!isDesktop) { + return ( + + + + {popup.title} + + +
+ + <> + {body} + + + +
+
+
+ ) + } + + return ( + + + {popup.title} + + {body} + + + + + + + + + + + + + + + + + ) +} diff --git a/components/tour/tourMap.tsx b/components/tour/tourMap.tsx new file mode 100644 index 0000000..afff9b3 --- /dev/null +++ b/components/tour/tourMap.tsx @@ -0,0 +1,360 @@ +import React, { useState, createRef, useMemo } from 'react' +import Link from 'next/link' +import Image from 'next/image' + +import Map, { MapRef, Marker } from 'react-map-gl/maplibre' +import { useMediaQuery } from 'usehooks-ts' +import { ChevronsLeft, ImagePlay, MapPin, Menu } from 'lucide-react' +import { TourData } from '@/pages/tour/[city]' +import { + Dialog, + DialogContent, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog' +import { Button } from '@/components/ui/button' +import { ScrollArea } from '@/components/ui/scroll-area' +import { + Collapsible, + CollapsibleContent, + CollapsibleTrigger, +} from '@/components/ui/collapsible' +import TourDetails from '@/components/tour/tourDetails' +import { cn } from '@/lib/utils' +import { + Drawer, + DrawerClose, + DrawerContent, + DrawerDescription, + DrawerFooter, + DrawerHeader, + DrawerTitle, +} from '@/components/ui/drawer' +import { AttributionControl } from '@/components/map/attributionControl' +import MapStyleControl from '@/components/map/styleControl' + +interface CategoryProps { + fill: string + stroke: string +} + +const COLORS: Record = { + gastro: { + fill: '#2ECC71', + stroke: '#367a4b', + }, + hochschule: { + fill: '#3498DB', + stroke: '#2c6189', + }, + chill: { + fill: '#F39C12', + stroke: '#9f6b26', + }, + sehenswuerdig: { + fill: '#E74C3C', + stroke: '#8f392c', + }, + nuetzlich: { + fill: '#BDC3C7', + stroke: '#74787a', + }, +} + +const HEADINGS: Record = { + gastro: 'Bars & Cafés', + hochschule: 'Hochschule', + chill: 'Chillen', + sehenswuerdig: 'Sehenswürdigkeiten', + nuetzlich: 'Nützliches', +} + +interface TourMapProps { + center: [number, number] + data: TourData[] +} + +export type MapStyle = 'bright' | 'light' | 'dark' + +export default function TourMap({ center, data }: TourMapProps) { + const [mapStyle, setMapStyle] = useState('bright') + const [dialogOpen, showDialog] = useState(true) + const [drawerOpen, setDrawer] = useState(false) + const [popup, setPopup] = useState(undefined) + const isDesktop = useMediaQuery('(min-width: 1024px)', { + defaultValue: true, + initializeWithValue: true, + }) + + const mapRef = createRef() + + // use less padding on small devices + // because they do not have enough space to fit the popup plus the padding + const PADDING = useMediaQuery('(max-width: 768px)') ? 10 : 50 + + const categorizedData = useMemo(() => { + return data + .map((elem) => elem.category) + .filter((v, i, a) => a.indexOf(v) === i) + .map((category) => { + const items = data + .map((elem, idx) => ({ + id: idx, + ...elem, + })) + .filter((elem) => elem.category === category) + .sort((a, b) => a.title.localeCompare(b.title)) + + return { category, items } + }) + }, [data]) + + const menuEntries = useMemo(() => { + return ( +
+ {categorizedData.map(({ category, items }) => ( + + + + {HEADINGS[category]} + + + + + {items + .filter((elem) => ('hide' in elem ? !elem.hide : true)) + .map((elem) => ( + + ))} + + + ))} +
+ ) + }, [PADDING, categorizedData, mapRef]) + + return ( + <> + + + + + Virtuelle Stadt- und Campusführung + + + +

+ Klicke auf eine Markierung, um mehr über diesen Ort zu erfahren. + Viele der Orte haben auch ein kurzes Video. +

+

+ Die virtuelle Stadt- und Campusführung ist ein Projekt der{' '} + + Fachschaft Informatik + {' '} + in Kooperation mit{' '} + + Neuland Ingolstadt + + . +

+ + + +
+
+ + + + + Virtuelle Stadt- und Campusführung + + + + {menuEntries} + + + + + + + + + + + +
+
+
+ + Studierendenvertretung TH Ingolstadt + + + + + +
+ + + {menuEntries} + +
+ +
+ + {data.map((elem, idx) => ( + { + e.originalEvent.stopPropagation() + mapRef.current?.getMap().flyTo({ + center: [elem.lon, elem.lat], + zoom: 16, + padding: PADDING, + }) + setPopup(elem) + }} + > + + + ))} + + + Map data from © OpenStreetMap + + } + /> + + + +
+ + + + + +
+
+
+ + ) +} diff --git a/components/ui/accordion.tsx b/components/ui/accordion.tsx new file mode 100644 index 0000000..4220aab --- /dev/null +++ b/components/ui/accordion.tsx @@ -0,0 +1,56 @@ +import * as React from 'react' +import * as AccordionPrimitive from '@radix-ui/react-accordion' +import { ChevronDown } from 'lucide-react' + +import { cn } from '@/lib/utils' + +const Accordion = AccordionPrimitive.Root + +const AccordionItem = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +AccordionItem.displayName = 'AccordionItem' + +const AccordionTrigger = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + + svg]:rotate-180', + className + )} + {...props} + > + {children} + + + +)) +AccordionTrigger.displayName = AccordionPrimitive.Trigger.displayName + +const AccordionContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + +
{children}
+
+)) + +AccordionContent.displayName = AccordionPrimitive.Content.displayName + +export { Accordion, AccordionItem, AccordionTrigger, AccordionContent } diff --git a/components/ui/button.tsx b/components/ui/button.tsx new file mode 100644 index 0000000..5f2702f --- /dev/null +++ b/components/ui/button.tsx @@ -0,0 +1,56 @@ +import * as React from 'react' +import { Slot } from '@radix-ui/react-slot' +import { cva, type VariantProps } from 'class-variance-authority' + +import { cn } from '@/lib/utils' + +const buttonVariants = cva( + 'inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0', + { + variants: { + variant: { + default: 'bg-primary text-primary-foreground hover:bg-primary/90', + destructive: + 'bg-destructive text-destructive-foreground hover:bg-destructive/90', + outline: + 'border border-input bg-background hover:bg-accent hover:text-accent-foreground', + secondary: + 'bg-secondary text-secondary-foreground hover:bg-secondary/80', + ghost: 'hover:bg-accent hover:text-accent-foreground', + link: 'text-primary underline-offset-4 hover:underline', + }, + size: { + default: 'h-10 px-4 py-2', + sm: 'h-9 rounded-md px-3', + lg: 'h-11 rounded-md px-8', + icon: 'h-10 w-10', + }, + }, + defaultVariants: { + variant: 'default', + size: 'default', + }, + } +) + +export interface ButtonProps + extends React.ButtonHTMLAttributes, + VariantProps { + asChild?: boolean +} + +const Button = React.forwardRef( + ({ className, variant, size, asChild = false, ...props }, ref) => { + const Comp = asChild ? Slot : 'button' + return ( + + ) + } +) +Button.displayName = 'Button' + +export { Button, buttonVariants } diff --git a/components/ui/card.tsx b/components/ui/card.tsx new file mode 100644 index 0000000..822ac17 --- /dev/null +++ b/components/ui/card.tsx @@ -0,0 +1,83 @@ +import * as React from 'react' + +import { cn } from '@/lib/utils' + +const Card = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
+)) +Card.displayName = 'Card' + +const CardHeader = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
+)) +CardHeader.displayName = 'CardHeader' + +const CardTitle = React.forwardRef< + HTMLParagraphElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +

+)) +CardTitle.displayName = 'CardTitle' + +const CardDescription = React.forwardRef< + HTMLParagraphElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +

+)) +CardDescription.displayName = 'CardDescription' + +const CardContent = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +

+)) +CardContent.displayName = 'CardContent' + +const CardFooter = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
+)) +CardFooter.displayName = 'CardFooter' + +export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent } diff --git a/components/ui/carousel.tsx b/components/ui/carousel.tsx new file mode 100644 index 0000000..c16a595 --- /dev/null +++ b/components/ui/carousel.tsx @@ -0,0 +1,263 @@ +import * as React from 'react' +import useEmblaCarousel, { + type UseEmblaCarouselType, +} from 'embla-carousel-react' +import { ArrowLeft, ArrowRight } from 'lucide-react' + +import { cn } from '@/lib/utils' +import { Button } from '@/components/ui/button' + +type CarouselApi = UseEmblaCarouselType[1] +type UseCarouselParameters = Parameters +type CarouselOptions = UseCarouselParameters[0] +type CarouselPlugin = UseCarouselParameters[1] + +type CarouselProps = { + opts?: CarouselOptions + plugins?: CarouselPlugin + orientation?: 'horizontal' | 'vertical' + setApi?: (api: CarouselApi) => void +} + +type CarouselContextProps = { + carouselRef: ReturnType[0] + api: ReturnType[1] + scrollPrev: () => void + scrollNext: () => void + canScrollPrev: boolean + canScrollNext: boolean +} & CarouselProps + +const CarouselContext = React.createContext(null) + +function useCarousel() { + const context = React.useContext(CarouselContext) + + if (!context) { + throw new Error('useCarousel must be used within a ') + } + + return context +} + +const Carousel = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes & CarouselProps +>( + ( + { + orientation = 'horizontal', + opts, + setApi, + plugins, + className, + children, + ...props + }, + ref + ) => { + const [carouselRef, api] = useEmblaCarousel( + { + ...opts, + axis: orientation === 'horizontal' ? 'x' : 'y', + }, + plugins + ) + const [canScrollPrev, setCanScrollPrev] = React.useState(false) + const [canScrollNext, setCanScrollNext] = React.useState(false) + + const onSelect = React.useCallback((api: CarouselApi) => { + if (!api) { + return + } + + setCanScrollPrev(api.canScrollPrev()) + setCanScrollNext(api.canScrollNext()) + }, []) + + const scrollPrev = React.useCallback(() => { + api?.scrollPrev() + }, [api]) + + const scrollNext = React.useCallback(() => { + api?.scrollNext() + }, [api]) + + const handleKeyDown = React.useCallback( + (event: React.KeyboardEvent) => { + if (event.key === 'ArrowLeft') { + event.preventDefault() + scrollPrev() + } else if (event.key === 'ArrowRight') { + event.preventDefault() + scrollNext() + } + }, + [scrollPrev, scrollNext] + ) + + React.useEffect(() => { + if (!api || !setApi) { + return + } + + setApi(api) + }, [api, setApi]) + + React.useEffect(() => { + if (!api) { + return + } + + onSelect(api) + api.on('reInit', onSelect) + api.on('select', onSelect) + + return () => { + api?.off('select', onSelect) + } + }, [api, onSelect]) + + return ( + +
+ {children} +
+
+ ) + } +) +Carousel.displayName = 'Carousel' + +const CarouselContent = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => { + const { carouselRef, orientation } = useCarousel() + + return ( +
+
+
+ ) +}) +CarouselContent.displayName = 'CarouselContent' + +const CarouselItem = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => { + const { orientation } = useCarousel() + + return ( +
+ ) +}) +CarouselItem.displayName = 'CarouselItem' + +const CarouselPrevious = React.forwardRef< + HTMLButtonElement, + React.ComponentProps +>(({ className, variant = 'outline', size = 'icon', ...props }, ref) => { + const { orientation, scrollPrev, canScrollPrev } = useCarousel() + + return ( + + ) +}) +CarouselPrevious.displayName = 'CarouselPrevious' + +const CarouselNext = React.forwardRef< + HTMLButtonElement, + React.ComponentProps +>(({ className, variant = 'outline', size = 'icon', ...props }, ref) => { + const { orientation, scrollNext, canScrollNext } = useCarousel() + + return ( + + ) +}) +CarouselNext.displayName = 'CarouselNext' + +export { + type CarouselApi, + Carousel, + CarouselContent, + CarouselItem, + CarouselPrevious, + CarouselNext, +} diff --git a/components/ui/collapsible.tsx b/components/ui/collapsible.tsx new file mode 100644 index 0000000..7cee61e --- /dev/null +++ b/components/ui/collapsible.tsx @@ -0,0 +1,9 @@ +import * as CollapsiblePrimitive from '@radix-ui/react-collapsible' + +const Collapsible = CollapsiblePrimitive.Root + +const CollapsibleTrigger = CollapsiblePrimitive.CollapsibleTrigger + +const CollapsibleContent = CollapsiblePrimitive.CollapsibleContent + +export { Collapsible, CollapsibleTrigger, CollapsibleContent } diff --git a/components/ui/dialog.tsx b/components/ui/dialog.tsx new file mode 100644 index 0000000..ef6627a --- /dev/null +++ b/components/ui/dialog.tsx @@ -0,0 +1,120 @@ +import * as React from 'react' +import * as DialogPrimitive from '@radix-ui/react-dialog' +import { X } from 'lucide-react' + +import { cn } from '@/lib/utils' + +const Dialog = DialogPrimitive.Root + +const DialogTrigger = DialogPrimitive.Trigger + +const DialogPortal = DialogPrimitive.Portal + +const DialogClose = DialogPrimitive.Close + +const DialogOverlay = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +DialogOverlay.displayName = DialogPrimitive.Overlay.displayName + +const DialogContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + + + + {children} + + + Close + + + +)) +DialogContent.displayName = DialogPrimitive.Content.displayName + +const DialogHeader = ({ + className, + ...props +}: React.HTMLAttributes) => ( +
+) +DialogHeader.displayName = 'DialogHeader' + +const DialogFooter = ({ + className, + ...props +}: React.HTMLAttributes) => ( +
+) +DialogFooter.displayName = 'DialogFooter' + +const DialogTitle = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +DialogTitle.displayName = DialogPrimitive.Title.displayName + +const DialogDescription = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +DialogDescription.displayName = DialogPrimitive.Description.displayName + +export { + Dialog, + DialogPortal, + DialogOverlay, + DialogClose, + DialogTrigger, + DialogContent, + DialogHeader, + DialogFooter, + DialogTitle, + DialogDescription, +} diff --git a/components/ui/drawer.tsx b/components/ui/drawer.tsx new file mode 100644 index 0000000..6652ea8 --- /dev/null +++ b/components/ui/drawer.tsx @@ -0,0 +1,116 @@ +import * as React from 'react' +import { Drawer as DrawerPrimitive } from 'vaul' + +import { cn } from '@/lib/utils' + +const Drawer = ({ + shouldScaleBackground = true, + ...props +}: React.ComponentProps) => ( + +) +Drawer.displayName = 'Drawer' + +const DrawerTrigger = DrawerPrimitive.Trigger + +const DrawerPortal = DrawerPrimitive.Portal + +const DrawerClose = DrawerPrimitive.Close + +const DrawerOverlay = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +DrawerOverlay.displayName = DrawerPrimitive.Overlay.displayName + +const DrawerContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + + + +
+ {children} + + +)) +DrawerContent.displayName = 'DrawerContent' + +const DrawerHeader = ({ + className, + ...props +}: React.HTMLAttributes) => ( +
+) +DrawerHeader.displayName = 'DrawerHeader' + +const DrawerFooter = ({ + className, + ...props +}: React.HTMLAttributes) => ( +
+) +DrawerFooter.displayName = 'DrawerFooter' + +const DrawerTitle = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +DrawerTitle.displayName = DrawerPrimitive.Title.displayName + +const DrawerDescription = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +DrawerDescription.displayName = DrawerPrimitive.Description.displayName + +export { + Drawer, + DrawerPortal, + DrawerOverlay, + DrawerTrigger, + DrawerClose, + DrawerContent, + DrawerHeader, + DrawerFooter, + DrawerTitle, + DrawerDescription, +} diff --git a/components/ui/dropdown-menu.tsx b/components/ui/dropdown-menu.tsx new file mode 100644 index 0000000..0168b52 --- /dev/null +++ b/components/ui/dropdown-menu.tsx @@ -0,0 +1,198 @@ +import * as React from 'react' +import * as DropdownMenuPrimitive from '@radix-ui/react-dropdown-menu' +import { Check, ChevronRight, Circle } from 'lucide-react' + +import { cn } from '@/lib/utils' + +const DropdownMenu = DropdownMenuPrimitive.Root + +const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger + +const DropdownMenuGroup = DropdownMenuPrimitive.Group + +const DropdownMenuPortal = DropdownMenuPrimitive.Portal + +const DropdownMenuSub = DropdownMenuPrimitive.Sub + +const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup + +const DropdownMenuSubTrigger = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef & { + inset?: boolean + } +>(({ className, inset, children, ...props }, ref) => ( + + {children} + + +)) +DropdownMenuSubTrigger.displayName = + DropdownMenuPrimitive.SubTrigger.displayName + +const DropdownMenuSubContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +DropdownMenuSubContent.displayName = + DropdownMenuPrimitive.SubContent.displayName + +const DropdownMenuContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, sideOffset = 4, ...props }, ref) => ( + + + +)) +DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName + +const DropdownMenuItem = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef & { + inset?: boolean + } +>(({ className, inset, ...props }, ref) => ( + +)) +DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName + +const DropdownMenuCheckboxItem = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, checked, ...props }, ref) => ( + + + + + + + {children} + +)) +DropdownMenuCheckboxItem.displayName = + DropdownMenuPrimitive.CheckboxItem.displayName + +const DropdownMenuRadioItem = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + + + + + + + {children} + +)) +DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName + +const DropdownMenuLabel = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef & { + inset?: boolean + } +>(({ className, inset, ...props }, ref) => ( + +)) +DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName + +const DropdownMenuSeparator = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName + +const DropdownMenuShortcut = ({ + className, + ...props +}: React.HTMLAttributes) => { + return ( + + ) +} +DropdownMenuShortcut.displayName = 'DropdownMenuShortcut' + +export { + DropdownMenu, + DropdownMenuTrigger, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuCheckboxItem, + DropdownMenuRadioItem, + DropdownMenuLabel, + DropdownMenuSeparator, + DropdownMenuShortcut, + DropdownMenuGroup, + DropdownMenuPortal, + DropdownMenuSub, + DropdownMenuSubContent, + DropdownMenuSubTrigger, + DropdownMenuRadioGroup, +} diff --git a/components/ui/footer.tsx b/components/ui/footer.tsx new file mode 100644 index 0000000..1d4b41a --- /dev/null +++ b/components/ui/footer.tsx @@ -0,0 +1,54 @@ +import { Button } from '@/components/ui/button' +import Link from 'next/link' + +export default function Footer() { + return ( +
+
+

+ Ein Projekt der{' '} + + Fachschaft Informatik (StudVer) + {' '} + in Kooperation mit{' '} + + Neuland Ingolstadt e.V. + +

+

+ Wir würden uns über euer Feedback freuen – entweder über Discord + oder per E-Mail. +

+
+ +
    + + + + + + +
+
+ ) +} diff --git a/components/ui/form.tsx b/components/ui/form.tsx new file mode 100644 index 0000000..e43a45f --- /dev/null +++ b/components/ui/form.tsx @@ -0,0 +1,180 @@ +import * as React from 'react' +import * as LabelPrimitive from '@radix-ui/react-label' +import { Slot } from '@radix-ui/react-slot' +import { + Controller, + ControllerProps, + FieldPath, + FieldValues, + FormProvider, + useFormContext, +} from 'react-hook-form' + +import { cn } from '@/lib/utils' +import { Label } from '@/components/ui/label' + +const Form = FormProvider + +type FormFieldContextValue< + TFieldValues extends FieldValues = FieldValues, + TName extends FieldPath = FieldPath, +> = { + name: TName +} + +const FormFieldContext = React.createContext( + {} as FormFieldContextValue +) + +const FormField = < + TFieldValues extends FieldValues = FieldValues, + TName extends FieldPath = FieldPath, +>({ + ...props +}: ControllerProps) => { + return ( + + + + ) +} + +const useFormField = () => { + const fieldContext = React.useContext(FormFieldContext) + const itemContext = React.useContext(FormItemContext) + const { getFieldState, formState } = useFormContext() + + const fieldState = getFieldState(fieldContext.name, formState) + + if (!fieldContext) { + throw new Error('useFormField should be used within ') + } + + const { id } = itemContext + + return { + id, + name: fieldContext.name, + formItemId: `${id}-form-item`, + formDescriptionId: `${id}-form-item-description`, + formMessageId: `${id}-form-item-message`, + ...fieldState, + } +} + +type FormItemContextValue = { + id: string +} + +const FormItemContext = React.createContext( + {} as FormItemContextValue +) + +const FormItem = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => { + const id = React.useId() + + return ( + +
+ + ) +}) +FormItem.displayName = 'FormItem' + +const FormLabel = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => { + const { error, formItemId } = useFormField() + + return ( +