This commit is contained in:
@@ -1,3 +1,6 @@
|
|||||||
EMAIL=username@spsejecna.cz
|
EMAIL=username@spsejecna.cz
|
||||||
PASSWORD=mojesupertajneheslo
|
PASSWORD=mojesupertajneheslo
|
||||||
SHAREPOINT_URL=https://spsejecnacz.sharepoint.com/:x:/s/nastenka/ESy19K245Y9BouR5ksciMvgBu3Pn_9EaT0fpP8R6MrkEmg
|
SHAREPOINT_URL=https://spsejecnacz.sharepoint.com/:x:/s/nastenka/ESy19K245Y9BouR5ksciMvgBu3Pn_9EaT0fpP8R6MrkEmg
|
||||||
|
|
||||||
|
# For the viewer
|
||||||
|
API_URl=http://localhost:3000
|
||||||
|
|||||||
1
output.json
Normal file
1
output.json
Normal file
File diff suppressed because one or more lines are too long
@@ -9,7 +9,7 @@
|
|||||||
"scripts": {
|
"scripts": {
|
||||||
"test": "tsx tests/test.ts",
|
"test": "tsx tests/test.ts",
|
||||||
"start": "concurrently \"NODE_ENV=development tsx server.ts\" \"NODE_ENV=development tsx cron-runner.ts\"",
|
"start": "concurrently \"NODE_ENV=development tsx server.ts\" \"NODE_ENV=development tsx cron-runner.ts\"",
|
||||||
"build": "tsc && cd web && hugo --gc --minify",
|
"build": "tsc && cd web && hugo --gc --minify && cd ../viewer && npm run build",
|
||||||
"build-noweb": "tsc",
|
"build-noweb": "tsc",
|
||||||
"serve": "concurrently \"node dist/server.js\" \"node dist/cron-runner.js\"",
|
"serve": "concurrently \"node dist/server.js\" \"node dist/cron-runner.js\"",
|
||||||
"dev-web": "cd web && hugo serve",
|
"dev-web": "cd web && hugo serve",
|
||||||
|
|||||||
@@ -131,9 +131,10 @@ if (SERVE_WEB) {
|
|||||||
const __dirname = path.dirname(__filename)
|
const __dirname = path.dirname(__filename)
|
||||||
const dev = process.env.NODE_ENV !== 'production'
|
const dev = process.env.NODE_ENV !== 'production'
|
||||||
|
|
||||||
|
// @ts-ignore
|
||||||
const nextApp = next({
|
const nextApp = next({
|
||||||
dev,
|
dev,
|
||||||
dir: path.join(__dirname, 'viewer')
|
dir: path.join(__dirname, '../viewer')
|
||||||
})
|
})
|
||||||
|
|
||||||
const handle = nextApp.getRequestHandler()
|
const handle = nextApp.getRequestHandler()
|
||||||
|
|||||||
@@ -15,6 +15,7 @@
|
|||||||
],
|
],
|
||||||
"exclude": [
|
"exclude": [
|
||||||
"node_modules",
|
"node_modules",
|
||||||
"web"
|
"web",
|
||||||
|
"viewer"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,7 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useState, useMemo, useTransition, useEffect } from 'react';
|
import { useState, useEffect, useMemo } from 'react';
|
||||||
import { format, parseISO } from 'date-fns';
|
import { format, parseISO } from 'date-fns';
|
||||||
import { RefreshCw } from 'lucide-react';
|
|
||||||
import { useRouter } from 'next/navigation';
|
|
||||||
|
|
||||||
import { SubstitutionData, ChangeEntry } from '@/lib/types';
|
import { SubstitutionData, ChangeEntry } from '@/lib/types';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
@@ -18,14 +16,13 @@ import {
|
|||||||
import { Label } from '@/components/ui/label';
|
import { Label } from '@/components/ui/label';
|
||||||
import TakesPlace from '@/components/own/takes-place';
|
import TakesPlace from '@/components/own/takes-place';
|
||||||
import { TeacherAbsenceItem } from '@/components/own/teacher-absence';
|
import { TeacherAbsenceItem } from '@/components/own/teacher-absence';
|
||||||
|
import UpdateStatus from '@/components/own/update-status';
|
||||||
|
|
||||||
interface SubstitutionViewerProps {
|
interface SubstitutionViewerProps {
|
||||||
initialData: SubstitutionData | null;
|
initialData: SubstitutionData | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function SubstitutionViewer({ initialData }: SubstitutionViewerProps) {
|
export default function SubstitutionViewer({ initialData }: SubstitutionViewerProps) {
|
||||||
const router = useRouter();
|
|
||||||
const [isPending, startTransition] = useTransition();
|
|
||||||
const data = initialData;
|
const data = initialData;
|
||||||
|
|
||||||
const [selectedDate, setSelectedDate] = useState<string>('');
|
const [selectedDate, setSelectedDate] = useState<string>('');
|
||||||
@@ -43,12 +40,6 @@ export default function SubstitutionViewer({ initialData }: SubstitutionViewerPr
|
|||||||
|
|
||||||
const currentDayData = data?.schedule?.[selectedDate];
|
const currentDayData = data?.schedule?.[selectedDate];
|
||||||
|
|
||||||
const handleRefresh = () => {
|
|
||||||
startTransition(() => {
|
|
||||||
router.refresh();
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const dates = data ? Object.keys(data.schedule).sort() : [];
|
const dates = data ? Object.keys(data.schedule).sort() : [];
|
||||||
|
|
||||||
if (!data) {
|
if (!data) {
|
||||||
@@ -63,20 +54,7 @@ export default function SubstitutionViewer({ initialData }: SubstitutionViewerPr
|
|||||||
return (
|
return (
|
||||||
|
|
||||||
<main className="flex-1 w-full max-w-[1920px] mx-auto p-4 md:p-6 space-y-6">
|
<main className="flex-1 w-full max-w-[1920px] mx-auto p-4 md:p-6 space-y-6">
|
||||||
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4 text-sm text-muted-foreground p-4 rounded-lg border">
|
<UpdateStatus data={data} />
|
||||||
<div>
|
|
||||||
<span className="font-medium">Poslední aktualizace:</span> {data.status.lastUpdated}
|
|
||||||
<span className="mx-2 hidden sm:inline">•</span>
|
|
||||||
<br className="sm:hidden" />
|
|
||||||
<span >
|
|
||||||
Aktualizace každých {data.status.currentUpdateSchedule < 60 ? `${data.status.currentUpdateSchedule} min` : `${data.status.currentUpdateSchedule / 60} hod`}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<Button variant="outline" size="sm" onClick={handleRefresh} disabled={isPending} className="w-full sm:w-auto">
|
|
||||||
<RefreshCw className={`h-4 w-4 mr-2 ${isPending ? 'animate-spin' : ''}`} />
|
|
||||||
Aktualizovat
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="max-w-md">
|
<div className="max-w-md">
|
||||||
<Label htmlFor="date-select" className="mb-2 block">Datum</Label>
|
<Label htmlFor="date-select" className="mb-2 block">Datum</Label>
|
||||||
|
|||||||
@@ -3,10 +3,8 @@ import { Geist, Geist_Mono } from "next/font/google";
|
|||||||
import "./globals.css";
|
import "./globals.css";
|
||||||
import { ThemeProvider } from "@/components/theme-provider";
|
import { ThemeProvider } from "@/components/theme-provider";
|
||||||
import { Toaster } from "@/components/ui/sonner";
|
import { Toaster } from "@/components/ui/sonner";
|
||||||
|
import { SiteHeader } from "@/components/site-header";
|
||||||
import { AlertTriangle, InfoIcon, Menu } from 'lucide-react';
|
import { InfoIcon } from 'lucide-react';
|
||||||
|
|
||||||
import { Button } from '@/components/ui/button';
|
|
||||||
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
||||||
|
|
||||||
const geistSans = Geist({
|
const geistSans = Geist({
|
||||||
@@ -40,24 +38,10 @@ export default function RootLayout({
|
|||||||
disableTransitionOnChange
|
disableTransitionOnChange
|
||||||
>
|
>
|
||||||
<div className="flex flex-col min-h-screen">
|
<div className="flex flex-col min-h-screen">
|
||||||
<header className="sticky top-0 z-20 flex items-center justify-between px-4 py-3 border-b bg-background">
|
<SiteHeader />
|
||||||
<div className="flex items-center gap-3">
|
{children}
|
||||||
<Button variant="ghost" size="icon" className="md:hidden">
|
<div className="w-full flex justify-center pt-4 pb-8">
|
||||||
<Menu className="h-5 w-5" />
|
<Alert className="max-w-100 mx-auto">
|
||||||
</Button>
|
|
||||||
<h1 className="text-lg font-semibold">
|
|
||||||
Mimořádný rozvrh
|
|
||||||
</h1>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Button variant="ghost" size="icon" title="Nahlásit chybu">
|
|
||||||
<AlertTriangle className="h-5 w-5 text-amber-500" />
|
|
||||||
<span className="sr-only">Nahlásit chybu</span>
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
<div className="w-full flex justify-center pt-8">
|
|
||||||
<Alert className="max-w-100">
|
|
||||||
<InfoIcon />
|
<InfoIcon />
|
||||||
<AlertTitle>Pozor!</AlertTitle>
|
<AlertTitle>Pozor!</AlertTitle>
|
||||||
<AlertDescription>
|
<AlertDescription>
|
||||||
@@ -65,7 +49,6 @@ export default function RootLayout({
|
|||||||
</AlertDescription>
|
</AlertDescription>
|
||||||
</Alert>
|
</Alert>
|
||||||
</div>
|
</div>
|
||||||
{children}
|
|
||||||
<footer className="text-center text-xs text-white/70 pb-4">
|
<footer className="text-center text-xs text-white/70 pb-4">
|
||||||
© 2026{" "}
|
© 2026{" "}
|
||||||
<a href="https://jzitnik.dev" target="_blank" className="underline hover:text-white/90">Jakub Žitník</a>{" "}
|
<a href="https://jzitnik.dev" target="_blank" className="underline hover:text-white/90">Jakub Žitník</a>{" "}
|
||||||
|
|||||||
@@ -16,6 +16,8 @@ import { Input } from "@/components/ui/input";
|
|||||||
import { SubstitutionData, LocalData } from "@/lib/types";
|
import { SubstitutionData, LocalData } from "@/lib/types";
|
||||||
import { Card, CardContent } from "@/components/ui/card";
|
import { Card, CardContent } from "@/components/ui/card";
|
||||||
import ScheduleViewer from "@/components/own/schedule-viewer";
|
import ScheduleViewer from "@/components/own/schedule-viewer";
|
||||||
|
import { capitalizeFirstLetter } from "@/lib/utils";
|
||||||
|
import UpdateStatus from '@/components/own/update-status';
|
||||||
|
|
||||||
interface ViewProps {
|
interface ViewProps {
|
||||||
data: SubstitutionData | null;
|
data: SubstitutionData | null;
|
||||||
@@ -29,6 +31,7 @@ interface FormValues {
|
|||||||
export default function View({ data }: ViewProps) {
|
export default function View({ data }: ViewProps) {
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [localData, setLocalData] = useState<LocalData | null>(null);
|
const [localData, setLocalData] = useState<LocalData | null>(null);
|
||||||
|
const [hideSubstitutions, setHideSubstitutions] = useState(false);
|
||||||
|
|
||||||
const form = useForm<FormValues>({
|
const form = useForm<FormValues>({
|
||||||
defaultValues: {
|
defaultValues: {
|
||||||
@@ -59,7 +62,7 @@ export default function View({ data }: ViewProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const data = {
|
const data = {
|
||||||
class: classNameProcessed,
|
class: foundKey,
|
||||||
timetable: jsonData[foundKey]
|
timetable: jsonData[foundKey]
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -147,10 +150,18 @@ export default function View({ data }: ViewProps) {
|
|||||||
return (
|
return (
|
||||||
<div className="w-full max-w-[1920px] mx-auto p-4 space-y-6">
|
<div className="w-full max-w-[1920px] mx-auto p-4 space-y-6">
|
||||||
<div className="flex justify-between items-center">
|
<div className="flex justify-between items-center">
|
||||||
<h1 className="text-2xl font-bold">Rozvrh třídy {localData.class}</h1>
|
<h1 className="text-2xl font-bold">Rozvrh třídy {capitalizeFirstLetter(localData.class)}</h1>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button variant="outline" onClick={() => setHideSubstitutions(!hideSubstitutions)}>
|
||||||
|
{hideSubstitutions ? "Zobrazit suplování" : "Skrýt suplování"}
|
||||||
|
</Button>
|
||||||
<Button variant="outline" onClick={() => setLocalData(null)}>Změnit třídu/soubor</Button>
|
<Button variant="outline" onClick={() => setLocalData(null)}>Změnit třídu/soubor</Button>
|
||||||
</div>
|
</div>
|
||||||
<ScheduleViewer localData={localData} substitutionData={data} />
|
</div>
|
||||||
|
|
||||||
|
<UpdateStatus data={data} />
|
||||||
|
|
||||||
|
<ScheduleViewer localData={localData} substitutionData={data} hideSubstitutions={hideSubstitutions} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,10 +5,12 @@ import { format, startOfWeek, addDays, parseISO } from 'date-fns';
|
|||||||
import { cs } from 'date-fns/locale';
|
import { cs } from 'date-fns/locale';
|
||||||
import { LocalData, SubstitutionData, ChangeEntry, Hour } from '@/lib/types';
|
import { LocalData, SubstitutionData, ChangeEntry, Hour } from '@/lib/types';
|
||||||
import { Card } from '@/components/ui/card';
|
import { Card } from '@/components/ui/card';
|
||||||
|
import { capitalizeFirstLetter } from '@/lib/utils';
|
||||||
|
|
||||||
interface ScheduleViewerProps {
|
interface ScheduleViewerProps {
|
||||||
localData: LocalData;
|
localData: LocalData;
|
||||||
substitutionData: SubstitutionData | null;
|
substitutionData: SubstitutionData | null;
|
||||||
|
hideSubstitutions?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
function getChangesForClass(changes: Record<string, (ChangeEntry | null)[]> | undefined, className: string): (ChangeEntry | null)[] {
|
function getChangesForClass(changes: Record<string, (ChangeEntry | null)[]> | undefined, className: string): (ChangeEntry | null)[] {
|
||||||
@@ -22,7 +24,7 @@ function getChangesForClass(changes: Record<string, (ChangeEntry | null)[]> | un
|
|||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function ScheduleViewer({ localData, substitutionData }: ScheduleViewerProps) {
|
export default function ScheduleViewer({ localData, substitutionData, hideSubstitutions = false }: ScheduleViewerProps) {
|
||||||
const referenceDate = useMemo(() => {
|
const referenceDate = useMemo(() => {
|
||||||
if (substitutionData?.schedule) {
|
if (substitutionData?.schedule) {
|
||||||
const dates = Object.keys(substitutionData.schedule).sort();
|
const dates = Object.keys(substitutionData.schedule).sort();
|
||||||
@@ -51,7 +53,7 @@ export default function ScheduleViewer({ localData, substitutionData }: Schedule
|
|||||||
|
|
||||||
const currentWeekMaxHours = useMemo(() => {
|
const currentWeekMaxHours = useMemo(() => {
|
||||||
let max = maxHours;
|
let max = maxHours;
|
||||||
if (substitutionData?.schedule) {
|
if (!hideSubstitutions && substitutionData?.schedule) {
|
||||||
weekDays.forEach(date => {
|
weekDays.forEach(date => {
|
||||||
const dateStr = format(date, 'yyyy-MM-dd');
|
const dateStr = format(date, 'yyyy-MM-dd');
|
||||||
const dayData = substitutionData.schedule[dateStr];
|
const dayData = substitutionData.schedule[dateStr];
|
||||||
@@ -62,7 +64,7 @@ export default function ScheduleViewer({ localData, substitutionData }: Schedule
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
return max;
|
return max;
|
||||||
}, [maxHours, substitutionData, weekDays, localData.class]);
|
}, [maxHours, substitutionData, weekDays, localData.class, hideSubstitutions]);
|
||||||
|
|
||||||
const hours = Array.from({ length: currentWeekMaxHours }, (_, i) => i + 1);
|
const hours = Array.from({ length: currentWeekMaxHours }, (_, i) => i + 1);
|
||||||
|
|
||||||
@@ -115,7 +117,7 @@ export default function ScheduleViewer({ localData, substitutionData }: Schedule
|
|||||||
<td key={hourIndex} className="border-r min-w-[120px] h-full align-top relative p-0">
|
<td key={hourIndex} className="border-r min-w-[120px] h-full align-top relative p-0">
|
||||||
<CellContent
|
<CellContent
|
||||||
staticLessons={staticLessons}
|
staticLessons={staticLessons}
|
||||||
change={change}
|
change={hideSubstitutions ? null : change}
|
||||||
/>
|
/>
|
||||||
</td>
|
</td>
|
||||||
);
|
);
|
||||||
@@ -157,7 +159,7 @@ function CellContent({ staticLessons, change }: { staticLessons: Hour[], change:
|
|||||||
<div key={idx} className="flex-1 flex flex-col justify-between p-1 text-[10px] border-b min-h-[40px]">
|
<div key={idx} className="flex-1 flex flex-col justify-between p-1 text-[10px] border-b min-h-[40px]">
|
||||||
<div className='flex justify-between'>
|
<div className='flex justify-between'>
|
||||||
<div className="font-bold truncate">{lesson.subject}</div>
|
<div className="font-bold truncate">{lesson.subject}</div>
|
||||||
<span className="truncate opacity-70">{lesson.teacher.code}</span>
|
<span className="truncate opacity-70">{capitalizeFirstLetter(lesson.teacher.code)}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex justify-between">
|
<div className="flex justify-between">
|
||||||
<span className="truncate max-w-[40px]">{lesson.room}</span>
|
<span className="truncate max-w-[40px]">{lesson.room}</span>
|
||||||
@@ -175,7 +177,7 @@ function CellContent({ staticLessons, change }: { staticLessons: Hour[], change:
|
|||||||
<div className="font-bold text-lg text-primary">{lesson.subject}</div>
|
<div className="font-bold text-lg text-primary">{lesson.subject}</div>
|
||||||
<div className="flex justify-between items-end mt-1">
|
<div className="flex justify-between items-end mt-1">
|
||||||
<div className="font-mono font-medium">{lesson.room}</div>
|
<div className="font-mono font-medium">{lesson.room}</div>
|
||||||
<div className="text-[10px] opacity-80" title={lesson.teacher.name}>{lesson.teacher.code}</div>
|
<div className="text-[10px] opacity-80" title={lesson.teacher.name}>{capitalizeFirstLetter(lesson.teacher.code)}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
52
viewer/components/own/update-status.tsx
Normal file
52
viewer/components/own/update-status.tsx
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useTransition } from "react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { RefreshCw } from "lucide-react";
|
||||||
|
import { SubstitutionData } from "@/lib/types";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
|
||||||
|
interface UpdateStatusProps {
|
||||||
|
data: SubstitutionData | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function UpdateStatus({ data }: UpdateStatusProps) {
|
||||||
|
const router = useRouter();
|
||||||
|
const [isPending, startTransition] = useTransition();
|
||||||
|
|
||||||
|
if (!data) return null;
|
||||||
|
|
||||||
|
const handleRefresh = () => {
|
||||||
|
startTransition(() => {
|
||||||
|
router.refresh();
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4 text-sm text-muted-foreground p-4 rounded-lg border">
|
||||||
|
<div>
|
||||||
|
<span className="font-medium">Poslední aktualizace:</span> {data.status.lastUpdated}
|
||||||
|
<span className="mx-2 hidden sm:inline">•</span>
|
||||||
|
<br className="sm:hidden" />
|
||||||
|
<span>
|
||||||
|
Aktualizace každých{" "}
|
||||||
|
{data.status.currentUpdateSchedule < 60
|
||||||
|
? `${data.status.currentUpdateSchedule} min`
|
||||||
|
: `${data.status.currentUpdateSchedule / 60} hod`}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={handleRefresh}
|
||||||
|
disabled={isPending}
|
||||||
|
className="w-full sm:w-auto"
|
||||||
|
>
|
||||||
|
<RefreshCw
|
||||||
|
className={`h-4 w-4 mr-2 ${isPending ? "animate-spin" : ""}`}
|
||||||
|
/>
|
||||||
|
Aktualizovat
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
103
viewer/components/site-header.tsx
Normal file
103
viewer/components/site-header.tsx
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import Link from "next/link";
|
||||||
|
import { usePathname } from "next/navigation";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { Menu, X, AlertTriangle, Home, List } from "lucide-react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
export function SiteHeader() {
|
||||||
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
|
const pathname = usePathname();
|
||||||
|
|
||||||
|
const routes = [
|
||||||
|
{
|
||||||
|
href: "/",
|
||||||
|
label: "Třída",
|
||||||
|
active: pathname === "/",
|
||||||
|
icon: Home
|
||||||
|
},
|
||||||
|
{
|
||||||
|
href: "/all",
|
||||||
|
label: "Vše",
|
||||||
|
active: pathname === "/all",
|
||||||
|
icon: List
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<header className="sticky top-0 z-50 w-full border-b bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60">
|
||||||
|
<div className="flex h-14 items-center px-4 md:px-8">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
className="mr-2 px-0 text-base hover:bg-transparent focus-visible:bg-transparent focus-visible:ring-0 focus-visible:ring-offset-0 md:hidden"
|
||||||
|
onClick={() => setIsOpen(!isOpen)}
|
||||||
|
>
|
||||||
|
{isOpen ? <X className="h-6 w-6" /> : <Menu className="h-6 w-6" />}
|
||||||
|
<span className="sr-only">Toggle Menu</span>
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<div className="mr-4 hidden md:flex items-center">
|
||||||
|
<Link href="/" className="mr-6 flex items-center space-x-2">
|
||||||
|
<span className="hidden font-bold sm:inline-block">
|
||||||
|
Mimořádný rozvrh
|
||||||
|
</span>
|
||||||
|
</Link>
|
||||||
|
<nav className="flex items-center space-x-6 text-sm font-medium">
|
||||||
|
{routes.map((route) => (
|
||||||
|
<Link
|
||||||
|
key={route.href}
|
||||||
|
href={route.href}
|
||||||
|
className={cn(
|
||||||
|
"transition-colors hover:text-foreground/80 flex items-center gap-2",
|
||||||
|
route.active ? "text-foreground" : "text-foreground/60"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<route.icon className="h-4 w-4" />
|
||||||
|
{route.label}
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-1 items-center justify-between space-x-2 md:justify-end hidden">
|
||||||
|
<div className="w-full flex-1 md:w-auto md:flex-none">
|
||||||
|
<span className="font-bold md:hidden">Mimořádný rozvrh</span>
|
||||||
|
</div>
|
||||||
|
<nav className="flex items-center">
|
||||||
|
<Button variant="ghost" size="icon" title="Nahlásit chybu" asChild>
|
||||||
|
<Link href="https://github.com/jzitnik/tablescraper/issues/new" target="_blank" rel="noreferrer">
|
||||||
|
<AlertTriangle className="h-5 w-5 text-amber-500" />
|
||||||
|
<span className="sr-only">Nahlásit chybu</span>
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isOpen && (
|
||||||
|
<div className="md:hidden border-t p-4 space-y-4 bg-background animate-in slide-in-from-top-5">
|
||||||
|
<nav className="flex flex-col space-y-4">
|
||||||
|
{routes.map((route) => (
|
||||||
|
<Link
|
||||||
|
key={route.href}
|
||||||
|
href={route.href}
|
||||||
|
onClick={() => setIsOpen(false)}
|
||||||
|
className={cn(
|
||||||
|
"text-sm font-medium transition-colors hover:text-primary p-2 rounded-md hover:bg-muted",
|
||||||
|
route.active ? "bg-muted text-foreground" : "text-muted-foreground"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<route.icon className="h-5 w-5" />
|
||||||
|
{route.label}
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</header>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,8 +1,9 @@
|
|||||||
import { SubstitutionData } from "./types";
|
import { SubstitutionData } from "./types";
|
||||||
|
|
||||||
export async function getData(): Promise<SubstitutionData | null> {
|
export async function getData(): Promise<SubstitutionData | null> {
|
||||||
|
const apiUrl = process.env.API_URL || 'http://localhost:3000';
|
||||||
try {
|
try {
|
||||||
const res = await fetch('http://localhost:3000/versioned/v3', {
|
const res = await fetch(`${apiUrl}/versioned/v3`, {
|
||||||
next: { revalidate: 60 },
|
next: { revalidate: 60 },
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -4,3 +4,8 @@ import { twMerge } from "tailwind-merge"
|
|||||||
export function cn(...inputs: ClassValue[]) {
|
export function cn(...inputs: ClassValue[]) {
|
||||||
return twMerge(clsx(inputs))
|
return twMerge(clsx(inputs))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function capitalizeFirstLetter(string: string) {
|
||||||
|
if (!string) return string;
|
||||||
|
return string.charAt(0).toUpperCase() + string.slice(1);
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user