Version 1.0.0

This commit is contained in:
2025-05-10 16:52:45 +02:00
commit bed95bff35
459 changed files with 36475 additions and 0 deletions

264
resources/css/app.css Normal file
View File

@@ -0,0 +1,264 @@
@import url('https://fonts.googleapis.com/css2?family=Open+Sans:ital,wght@0,300..800;1,300..800&display=swap');
@import 'tailwindcss';
@plugin 'tailwindcss-animate';
@source '../views';
@source '../../vendor/laravel/framework/src/Illuminate/Pagination/resources/views/*.blade.php';
@custom-variant contrast (&:is(.contrast *));
@custom-variant dark (&:is(.dark *));
/* Font size variants */
.text-normal {
font-size: 1rem;
}
.text-large {
font-size: 1.125rem;
}
.text-larger {
font-size: 1.25rem;
}
@theme {
--font-sans:
'Open Sans', ui-sans-serif, system-ui, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol', 'Noto Color Emoji';
--radius-lg: var(--radius);
--radius-md: calc(var(--radius) - 2px);
--radius-sm: calc(var(--radius) - 4px);
--color-background: var(--background);
--color-foreground: var(--foreground);
--color-header-background: var(--header-background);
--color-header-text: var(--header-text);
--color-page-card: var(--page-card);
--color-card: var(--card);
--color-card-foreground: var(--card-foreground);
--color-popover: var(--popover);
--color-popover-foreground: var(--popover-foreground);
--color-primary: var(--primary);
--color-primary-foreground: var(--primary-foreground);
--color-secondary: var(--secondary);
--color-secondary-foreground: var(--secondary-foreground);
--color-muted: var(--muted);
--color-muted-foreground: var(--muted-foreground);
--color-accent: var(--accent);
--color-accent-foreground: var(--accent-foreground);
--color-destructive: var(--destructive);
--color-destructive-foreground: var(--destructive-foreground);
--color-border: var(--border);
--color-input: var(--input);
--color-ring: var(--ring);
--color-chart-1: var(--chart-1);
--color-chart-2: var(--chart-2);
--color-chart-3: var(--chart-3);
--color-chart-4: var(--chart-4);
--color-chart-5: var(--chart-5);
--color-sidebar: var(--sidebar);
--color-sidebar-foreground: var(--sidebar-foreground);
--color-sidebar-primary: var(--sidebar-primary);
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
--color-sidebar-accent: var(--sidebar-accent);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-ring: var(--sidebar-ring);
}
/*
The default border color has changed to `currentColor` in Tailwind CSS v4,
so we've added these compatibility styles to make sure everything still
looks the same as it did with Tailwind CSS v3.
If we ever want to remove these styles, we need to add an explicit border
color utility to any element that depends on these defaults.
*/
@layer base {
*,
::after,
::before,
::backdrop,
::file-selector-button {
border-color: var(--color-gray-200, currentColor);
}
}
:root {
--background: oklch(0.9897 0 0);
--foreground: oklch(0.22 0.04 265);
--header-background: oklch(0.22 0.04 265);
--header-text: oklch(1 0 0);
--page-card: oklch(0.94 0.004 264);
--card: oklch(1 0 0);
--card-foreground: oklch(0.22 0.04 265);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.22 0.04 265);
--primary: oklch(0.67 0.22 265);
--primary-foreground: oklch(1 0 0);
--secondary: oklch(0.82 0.13 265);
--secondary-foreground: oklch(0.22 0.04 265);
--muted: oklch(0.92 0.06 265);
--muted-foreground: oklch(0.4 0.04 265);
--accent: oklch(0.74 0.17 265);
--accent-foreground: oklch(0.22 0.04 265);
--destructive: oklch(0.65 0.22 25);
--destructive-foreground: oklch(1 0 0);
--border: oklch(0.82 0.13 265);
--input: oklch(0.82 0.13 265);
--ring: oklch(0.67 0.22 265);
--chart-1: oklch(0.67 0.22 265);
--chart-2: oklch(0.82 0.13 265);
--chart-3: oklch(0.92 0.06 265);
--chart-4: oklch(0.74 0.17 265);
--chart-5: oklch(0.4 0.04 265);
--radius: 0.625rem;
--sidebar: oklch(0.82 0.13 265);
--sidebar-foreground: oklch(0.22 0.04 265);
--sidebar-primary: oklch(0.67 0.22 265);
--sidebar-primary-foreground: oklch(1 0 0);
--sidebar-accent: oklch(0.74 0.17 265);
--sidebar-accent-foreground: oklch(0.22 0.04 265);
--sidebar-border: oklch(0.82 0.13 265);
--sidebar-ring: oklch(0.67 0.22 265);
}
.dark {
--background: oklch(0.24 0.04 265);
--foreground: oklch(0.98 0.04 265);
--header-background: oklch(0.98 0 0);
--header-text: oklch(0 0 0);
--page-card: oklch(0.18 0.02 264);
--card: oklch(0.28 0.04 265);
--card-foreground: oklch(0.98 0.04 265);
--popover: oklch(0.28 0.04 265);
--popover-foreground: oklch(0.98 0.04 265);
--primary: oklch(0.98 0 0);
--primary-foreground: oklch(0.18 0.02 260);
--secondary: oklch(0.4 0.04 265);
--secondary-foreground: oklch(0.98 0.04 265);
--muted: oklch(0.28 0.04 265);
--muted-foreground: oklch(0.82 0.13 265);
--accent: oklch(0.67 0.22 265);
--accent-foreground: oklch(0.98 0.04 265);
--destructive: oklch(0.65 0.22 25);
--destructive-foreground: oklch(1 0 0);
--border: oklch(0.4 0.04 265);
--input: oklch(0.4 0.04 265);
--ring: oklch(0.67 0.22 265);
--chart-1: oklch(0.74 0.17 265);
--chart-2: oklch(0.4 0.04 265);
--chart-3: oklch(0.28 0.04 265);
--chart-4: oklch(0.67 0.22 265);
--chart-5: oklch(0.98 0.04 265);
--sidebar: oklch(0.28 0.04 265);
--sidebar-foreground: oklch(0.98 0.04 265);
--sidebar-primary: oklch(0.74 0.17 265);
--sidebar-primary-foreground: oklch(0.22 0.04 265);
--sidebar-accent: oklch(0.67 0.22 265);
--sidebar-accent-foreground: oklch(0.98 0.04 265);
--sidebar-border: oklch(0.4 0.04 265);
--sidebar-ring: oklch(0.67 0.22 265);
}
.contrast {
--background: oklch(0 0 0);
--foreground: oklch(0.97 0.16 110);
--header-background: oklch(0 0 0);
--header-text: oklch(0.97 0.16 110);
--page-card: oklch(0 0 0);
--card: oklch(0 0 0);
--card-foreground: oklch(0.97 0.16 110);
--popover: oklch(0 0 0);
--popover-foreground: oklch(0.97 0.16 110);
--primary: oklch(0.97 0.16 110);
--primary-foreground: oklch(0 0 0);
--secondary: oklch(0.5 0.12 110);
--secondary-foreground: oklch(0 0 0);
--muted: oklch(0.15 0 0);
--muted-foreground: oklch(0.97 0.16 110);
--accent: oklch(0.97 0.16 110);
--accent-foreground: oklch(0 0 0);
--destructive: oklch(0.65 0.18 27);
--destructive-foreground: oklch(0 0 0);
--border: oklch(0.97 0.16 110);
--input: oklch(0.97 0.16 110);
--ring: oklch(0.97 0.16 110);
--chart-1: oklch(0.97 0.16 110);
--chart-2: oklch(0.97 0.16 110);
--chart-3: oklch(0.97 0.16 110);
--chart-4: oklch(0.97 0.16 110);
--chart-5: oklch(0.97 0.16 110);
--radius: 0.625rem;
--sidebar: oklch(0 0 0);
--sidebar-foreground: oklch(0.97 0.16 110);
--sidebar-primary: oklch(0.97 0.16 110);
--sidebar-primary-foreground: oklch(0 0 0);
--sidebar-accent: oklch(0.97 0.16 110);
--sidebar-accent-foreground: oklch(0 0 0);
--sidebar-border: oklch(0.97 0.16 110);
--sidebar-ring: oklch(0.97 0.16 110);
}
.content h2 {
font-size: 1.25rem; /* text-xl */
font-weight: 600;
margin-top: 1.25rem;
margin-bottom: 0.5rem;
line-height: 1.4;
}
.content h3 {
font-size: 1.125rem; /* text-lg */
font-weight: 600;
margin-top: 1rem;
margin-bottom: 0.5rem;
line-height: 1.5;
}
.content ul {
list-style-type: disc;
padding-left: 1rem;
margin-left: 1rem;
}
.content ol {
list-style-type: decimal;
padding-left: 1rem;
margin-left: 1rem;
}
.content img {
max-width: 100%;
height: auto;
object-fit: cover;
width: 80%;
}
.content p {
margin-bottom: 1rem;
line-height: 1.6;
}
@layer base {
* {
@apply border-border;
}
body {
@apply bg-background text-foreground;
}
}

32
resources/js/app.tsx Normal file
View File

@@ -0,0 +1,32 @@
import '../css/app.css';
import { createInertiaApp } from '@inertiajs/react';
import { resolvePageComponent } from 'laravel-vite-plugin/inertia-helpers';
import { createRoot } from 'react-dom/client';
import { initializeTheme } from './hooks/use-appearance';
import Analytics from './components/analytics';
const appName = import.meta.env.VITE_APP_NAME || 'Laravel';
createInertiaApp({
title: (title) => `${title} - ${appName}`,
resolve: (name) => resolvePageComponent(`./pages/${name}.tsx`, import.meta.glob('./pages/**/*.tsx')),
setup({ el, App, props }) {
const root = createRoot(el);
root.render(
<>
<Analytics
url={import.meta.env.VITE_ANALYTICS_URL}
websiteId={import.meta.env.VITE_ANALYTICS_WEBSITE_ID}
/>
<App {...props} />
</>
);
},
progress: {
color: '#4B5563',
},
});
initializeTheme();

View File

@@ -0,0 +1,25 @@
import { useEffect } from 'react';
interface AnalyticsProps {
url?: string;
websiteId?: string;
}
export default function Analytics({ url, websiteId }: AnalyticsProps) {
useEffect(() => {
if (!url || !websiteId) return;
const script = document.createElement('script');
script.src = url;
script.defer = true;
script.setAttribute('data-website-id', websiteId);
document.head.appendChild(script);
return () => {
document.head.removeChild(script);
};
}, [url, websiteId]);
return null;
}

View File

@@ -0,0 +1,34 @@
import { Appearance, useAppearance } from '@/hooks/use-appearance';
import { cn } from '@/lib/utils';
import { LucideIcon, Moon, Sun, Contrast } from 'lucide-react';
import { HTMLAttributes } from 'react';
export default function AppearanceToggleTab({ className = '', ...props }: HTMLAttributes<HTMLDivElement>) {
const { appearance, updateAppearance } = useAppearance();
const tabs: { value: Appearance; icon: LucideIcon; label: string }[] = [
{ value: 'light', icon: Sun, label: 'Tryb jasny' },
{ value: 'dark', icon: Moon, label: 'Tryb ciemny' },
{ value: 'contrast', icon: Contrast, label: 'Wysoki kontrast' },
];
return (
<div className={cn('inline-flex gap-1 rounded-lg bg-neutral-100 p-1 dark:bg-neutral-800 contrast:bg-primary select-none', className)} {...props}>
{tabs.map(({ value, icon: Icon, label }) => (
<button
key={value}
onClick={() => updateAppearance(value)}
className={cn(
'flex items-center rounded-md px-3.5 py-1.5 transition-colors',
appearance === value
? 'bg-white shadow-xs dark:bg-neutral-700 dark:text-neutral-100 contrast:bg-secondary contrast:text-white'
: 'text-neutral-500 hover:bg-neutral-200/60 hover:text-black dark:text-neutral-400 dark:hover:bg-neutral-700/60 contrast:text-black',
)}
>
<Icon className="-ml-1 h-4 w-4" />
<span className="ml-1.5 text-sm">{label}</span>
</button>
))}
</div>
);
}

View File

@@ -0,0 +1,45 @@
import React from 'react';
export interface Attachment {
id: number;
file_name: string;
file_path: string;
}
interface AttachmentsProps {
attachments: Attachment[];
title?: string;
className?: string;
}
export default function AttachmentsList({
attachments,
title = "Załączniki",
className = ""
}: AttachmentsProps) {
if (!attachments || attachments.length === 0) {
return null;
}
return (
<div className={`mt-8 w-fit ${className}`}>
<h2 className="text-2xl font-semibold mb-4">{title}</h2>
<div className="flex flex-col gap-2">
{attachments.map(attachment => (
<a
key={attachment.id}
href={attachment.file_path}
target="_blank"
rel="noopener noreferrer"
className="flex items-center w-fit p-3 pl-3 pr-6 bg-muted rounded-md hover:bg-muted/80 transition-colors"
>
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5 mr-2" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 10v6m0 0l-3-3m3 3l3-3M3 17V7a2 2 0 012-2h6l2 2h6a2 2 0 012 2v8a2 2 0 01-2 2H5a2 2 0 01-2-2z" />
</svg>
<span>{attachment.file_name}</span>
</a>
))}
</div>
</div>
);
};

View File

@@ -0,0 +1,36 @@
import { Breadcrumb, BreadcrumbItem, BreadcrumbLink, BreadcrumbList, BreadcrumbPage, BreadcrumbSeparator } from '@/components/ui/breadcrumb';
import { type BreadcrumbItem as BreadcrumbItemType } from '@/types';
import { Link } from '@inertiajs/react';
import { Fragment } from 'react';
export function Breadcrumbs({ breadcrumbs }: { breadcrumbs: BreadcrumbItemType[] }) {
return (
<>
{breadcrumbs.length > 0 && (
<Breadcrumb className="p-2">
<BreadcrumbList>
{breadcrumbs.map((item, index) => {
const isLast = index === breadcrumbs.length - 1;
return (
<Fragment key={index}>
<BreadcrumbItem>
{isLast ? (
<BreadcrumbPage>{item.title}</BreadcrumbPage>
) : item.disabled ? (
<BreadcrumbPage className="text-muted-foreground">{item.title}</BreadcrumbPage>
) : (
<BreadcrumbLink asChild>
<Link href={item.href}>{item.title}</Link>
</BreadcrumbLink>
)}
</BreadcrumbItem>
{!isLast && <BreadcrumbSeparator />}
</Fragment>
);
})}
</BreadcrumbList>
</Breadcrumb>
)}
</>
);
}

View File

@@ -0,0 +1,38 @@
import { useFontSize } from '@/hooks/use-font-size';
import { cn } from '@/lib/utils';
import { LucideIcon, Type } from 'lucide-react';
import { HTMLAttributes } from 'react';
export default function FontSizeToggleTab({ className = '', ...props }: HTMLAttributes<HTMLDivElement>) {
const { fontSize, setFontSize } = useFontSize();
const tabs: { value: 'normal' | 'large' | 'larger'; icon: LucideIcon; label: string }[] = [
{ value: 'normal', icon: Type, label: 'Normalny' },
{ value: 'large', icon: Type, label: 'Duży' },
{ value: 'larger', icon: Type, label: 'Bardzo duży' },
];
return (
<div className={cn('inline-flex gap-1 rounded-lg bg-neutral-100 p-1 dark:bg-neutral-800 contrast:bg-primary select-none', className)} {...props}>
{tabs.map(({ value, icon: Icon, label }) => (
<button
key={value}
onClick={() => setFontSize(value)}
className={cn(
'flex items-center rounded-md px-3.5 py-1.5 transition-colors',
fontSize === value
? 'bg-white shadow-xs dark:bg-neutral-700 dark:text-neutral-100 contrast:bg-secondary contrast:text-white'
: 'text-neutral-500 hover:bg-neutral-200/60 hover:text-black dark:text-neutral-400 dark:hover:bg-neutral-700/60 contrast:text-black',
)}
>
<Icon className={cn('-ml-1 w-4', {
'h-3': value === 'normal',
'h-4': value === 'large',
'h-5': value === 'larger',
})} />
<span className="ml-1.5 text-sm">{label}</span>
</button>
))}
</div>
);
}

View File

@@ -0,0 +1,94 @@
import React from 'react';
import { cn } from '@/lib/utils';
import { useState, useEffect } from 'react';
import { Link } from '@inertiajs/react';
interface FooterData {
wosp_link?: string;
registration_hours: Array<{
day: string;
hours: string;
}>;
links: Array<{
name: string;
url: string;
external: boolean;
}>;
}
interface FooterProps extends React.HTMLAttributes<HTMLElement> {}
const currentYear = new Date().getFullYear();
export default function Footer({ className, ...props }: FooterProps) {
const [footerData, setFooterData] = useState<FooterData | null>(null);
useEffect(() => {
fetch('/api/footer')
.then(response => response.json())
.then(data => setFooterData(data))
.catch(error => console.error('Error fetching footer data:', error));
}, []);
return (
<footer
className={cn(
'w-full border-t border-border/60 bg-black relative bottom-0 left-0',
className
)}
{...props}
>
<div className='w-full max-w-screen-lg m-auto py-8 px-4 flex text-gray-100 dark:text-gray-100 contrast:text-foreground text-lg flex flex-col md:flex-row md:justify-between md:items-start'>
<div className="flex flex-col items-start md:items-start">
<div className="font-semibold mb-1">Pomocne odnośniki</div>
{footerData?.links?.map((link, index) => link.external ? (
<a
key={index}
href={link.url}
target="_blank"
rel="noopener noreferrer"
title={link.name}
className="hover:underline"
>
{link.name}
</a>
) : (
<Link
key={index}
href={link.url}
title={link.name}
className="hover:underline"
>
{link.name}
</Link>
))}
</div>
<div className="flex flex-col items-start md:items-start mt-8 mb-8 md:mt-0 md:mb-0">
<div className="font-semibold mb-1">Godziny rejestracji</div>
<div className="flex flex-row">
<ul className='pr-4'>
{footerData?.registration_hours?.map((registration_hour, index) => (
<li key={index}>{registration_hour.day}</li>
))}
</ul>
<ul>
{footerData?.registration_hours?.map((registration_hour, index) => (
<li key={index}>{registration_hour.hours}</li>
))}
</ul>
</div>
</div>
<div className="flex flex-col items-center">
<Link href={footerData?.wosp_link || '#'} target="_blank" rel="noopener noreferrer">
<img src="/storage/wosp.webp" alt="Zdjęcie przedstawia naklejkę Wielkiej Orkiestry Świątecznej Pomocy, po kliknięciu otworzy się artykuł o wsparciu jakie otrzymaliśmy." title='Kliknij aby przejść do artykułu.' className="w-48 h-auto rounded hover:scale-105 transition-transform select-none" />
</Link>
</div>
</div>
<div className="w-full text-center py-4 text-md opacity-70 text-gray-100 dark:text-gray-100 contrast:text-foreground select-none">
<div>&copy; {currentYear} Samodzielny Publiczny Zakład Opieki Zdrowotnej w Obornikach. Wszelkie prawa zastrzeżone.<br />Wykonane przez <a href='https://bwitek.dev' className="font-semibold hover:underline" target="_blank" rel="noopener noreferrer">Bogusław Witek</a>. Utrzymywane przez <a href='https://e-tmk.com' className="font-semibold hover:underline" target="_blank" rel="noopener noreferrer">TMK</a>.</div>
</div>
</footer>
);
}

View File

@@ -0,0 +1,111 @@
import React from 'react';
import FontSizeToggleTab from '../font-size-tabs';
import AppearanceToggleTab from '../appearance-tabs';
import { cn } from '@/lib/utils';
import { useEffect, useState } from 'react';
import { SearchInput } from '@/components/search-input/search-input';
import { Phone } from 'lucide-react';
import { Link } from '@inertiajs/react';
interface HeaderData {
title1?: string;
title2?: string;
subtitle?: string;
logo?: string;
telephone: string;
links: Array<{
name: string;
icon?: string;
'icon-alt'?: string;
url: string;
external: boolean;
}>;
}
interface HeaderProps extends React.HTMLAttributes<HTMLElement> {}
export default function Header({ className, ...props }: HeaderProps) {
const [headerData, setHeaderData] = useState<HeaderData | null>(null);
useEffect(() => {
fetch('/api/header')
.then(response => response.json())
.then(data => setHeaderData(data))
.catch(error => console.error('Error fetching header data:', error));
}, []);
const linksMap = headerData?.links && headerData.links.length > 0 ? headerData.links.map((link, index) => link.external ? (
<a
key={index}
href={link.url}
target="_blank"
rel="noopener noreferrer"
title={link.name}
className="flex items-center gap-2 text-primary hover:underline"
>
{link.icon && (
<img
src={`/storage/${link.icon}`}
alt={link['icon-alt'] || link.name}
className="h-6 object-contain"
/>
)}
</a>
) : (
<Link
key={index}
href={link.url}
title={link.name}
className="flex items-center gap-2 text-primary hover:underline"
>
{link.icon && (
<img
src={`/storage/${link.icon}`}
alt={link['icon-alt'] || link.name}
className="h-6 object-contain"
/>
)}
</Link>
)) : null;
return (
<header>
<section className={cn('flex justify-center items-start p-2 bg-header-background flex-col sm:flex-row sm:justify-between', className)}>
<div className="flex items-center pb-6 sm:pb-0">
<div className="flex flex-row justify-center gap-4 select-none">
{linksMap}
</div>
</div>
<div className="flex flex-col items-left sm:items-end gap-2 lg:flex-row lg:items-center">
<FontSizeToggleTab />
<AppearanceToggleTab />
</div>
</section>
<section className="bg-header-background">
<div className='flex flex-col items-start justify-start text-center max-w-screen-xl mx-auto pt-8 pb-8 px-2 md:flex-row md:items-center md:justify-between'>
<div className='pb-6 md:pb-0'>
<div className='flex flex-row items-center justify-start'>
<img
src={headerData?.logo}
alt="Logo szpitala"
className="h-18 w-auto select-none"
/>
<div className="pl-4 pr-4 text-4xl font-semibold text-header-text flex flex-col items-start justify-center">
<div className='select-none'>{headerData?.title1}<span className="text-accent">{headerData?.title2}</span></div>
<div className="text-lg text-header-text pt-1 text-left md:text-center">{headerData?.subtitle}</div>
</div>
</div>
</div>
<div className="w-full sm:w-auto flex flex-col items-center justify-center xl:flex-row xl:items-center xl:justify-center xl:gap-8">
<a href={`tel:+48${headerData?.telephone.replaceAll(' ', '')}`} className="text-header-text text-2xl pb-2 font-bold flex items-center gap-2 justify-start">
<Phone className="h-6 w-6 mr-1" />{headerData?.telephone}
</a>
<div className="w-[250px]">
<SearchInput className="h-[40px]" />
</div>
</div>
</div>
</section>
</header>
);
}

View File

@@ -0,0 +1,78 @@
import * as React from "react";
import { Link, Head } from '@inertiajs/react';
import Autoplay from "embla-carousel-autoplay";
import { Card, CardContent } from "@/components/ui/card"
import {
Carousel,
CarouselContent,
CarouselItem,
CarouselNext,
CarouselPrevious,
} from "@/components/ui/carousel"
const SLIDES_COUNT = 13;
const AUTOPLAY_DELAY = 4000;
interface HomepageData {
title?: string;
content?: string;
photo?: string;
carouselPhotos: Array<{
name: string;
photo: string;
description: string;
url: string;
external: boolean;
}>;
}
interface CarouselProps extends React.HTMLAttributes<HTMLElement> {}
export default function HomeCarousel({ className, ...props }: CarouselProps) {
const [homepageData, setHomepageData] = React.useState<HomepageData | null>(null);
React.useEffect(() => {
fetch('/api/homepage')
.then(response => response.json())
.then(data => setHomepageData(data))
.catch(error => console.error('Error fetching carousel photos:', error));
}, []);
return (
<div className="w-full max-w-screen-sm md:max-w-screen-lg lg:max-w-screen-xl mx-auto px-12">
<Carousel
className="mt-8 mb-8 select-none"
plugins={[
Autoplay({
delay: AUTOPLAY_DELAY,
}),
]}
>
<CarouselContent className="-ml-1">
{homepageData?.carouselPhotos?.map((photo, index) => (
<CarouselItem key={index} className="pl-1 basis-3/3 md:basis-1/3 lg:basis-1/5">
<div className="p-1">
<Card className="p-0">
<CardContent className="flex aspect-square items-center justify-center p-0">
{photo.external ? (
<a href={photo.url} target="_blank" rel="noopener noreferrer" title={photo.name} className="cursor-pointer">
<img src={photo.photo} alt={photo.description} className="h-full object-cover rounded-lg" />
</a>
) : (
<Link href={photo.url} title={photo.name} className="cursor-pointer">
<img src={photo.photo} alt={photo.description} className="h-full object-cover rounded-lg" />
</Link>
)}
</CardContent>
</Card>
</div>
</CarouselItem>
))}
</CarouselContent>
<CarouselPrevious />
<CarouselNext />
</Carousel>
</div>
);
}

View File

@@ -0,0 +1,160 @@
import React, { useState, useEffect } from 'react';
import { Dialog, DialogContent, DialogTrigger, DialogTitle, DialogDescription } from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { ChevronLeft, ChevronRight, X } from 'lucide-react';
interface ImageLightboxProps {
images: Array<{
id: number;
image_path: string;
image_desc?: string;
}>;
initialIndex?: number;
}
export function ImageLightbox({ images, initialIndex = 0 }: ImageLightboxProps) {
const [open, setOpen] = useState(false);
const [currentIndex, setCurrentIndex] = useState(initialIndex);
useEffect(() => {
setCurrentIndex(initialIndex);
}, [initialIndex]);
useEffect(() => {
if (open) {
setCurrentIndex(initialIndex);
}
}, [open, initialIndex]);
const handlePrevious = (e: React.MouseEvent) => {
e.stopPropagation();
setCurrentIndex((prev) => (prev === 0 ? images.length - 1 : prev - 1));
};
const handleNext = (e: React.MouseEvent) => {
e.stopPropagation();
setCurrentIndex((prev) => (prev === images.length - 1 ? 0 : prev + 1));
};
// Handle keyboard navigation
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (!open) return;
if (e.key === 'ArrowLeft') {
setCurrentIndex((prev) => (prev === 0 ? images.length - 1 : prev - 1));
} else if (e.key === 'ArrowRight') {
setCurrentIndex((prev) => (prev === images.length - 1 ? 0 : prev + 1));
} else if (e.key === 'Escape') {
setOpen(false);
}
};
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, [open, images.length]);
if (!images.length) return null;
const currentImage = images[currentIndex];
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<div className="cursor-pointer overflow-hidden rounded-lg shadow-md hover:opacity-90 transition-opacity">
<img
src={images[initialIndex].image_path}
alt={images[initialIndex].image_desc || 'Zdjęcie'}
className="w-full h-64 object-cover"
loading="lazy"
/>
</div>
</DialogTrigger>
<DialogContent className="sm:max-w-4xl p-0 bg-transparent border-none shadow-none [&>button]:hidden">
<DialogTitle className="sr-only">{currentImage.image_desc || 'Zdjęcie'}</DialogTitle>
<DialogDescription className="sr-only">Przeglądarka zdjęć</DialogDescription>
<div className="fixed inset-0 bg-black/90 flex items-center justify-center">
<div className="relative flex items-center justify-center w-full max-w-4xl">
<div className="relative">
<img
src={currentImage.image_path}
alt={currentImage.image_desc || 'Zdjęcie'}
className="max-h-[80vh] max-w-[90vw] object-contain rounded-lg"
style={{ aspectRatio: 'auto' }}
/>
<Button
variant="outline"
size="icon"
className="absolute left-2 top-1/2 -translate-y-1/2 z-10 bg-white/80 hover:bg-white dark:bg-neutral-800 dark:hover:bg-neutral-700"
onClick={handlePrevious}
>
<ChevronLeft className="h-6 w-6" />
</Button>
<Button
variant="outline"
size="icon"
className="absolute right-2 top-1/2 -translate-y-1/2 z-10 bg-white/80 hover:bg-white dark:bg-neutral-800 dark:hover:bg-neutral-700"
onClick={handleNext}
>
<ChevronRight className="h-6 w-6" />
</Button>
<Button
variant="outline"
size="icon"
className="absolute top-2 right-2 z-10 bg-white/80 hover:bg-white dark:bg-neutral-800 dark:hover:bg-neutral-700"
onClick={() => setOpen(false)}
>
<X className="h-4 w-4" />
</Button>
</div>
</div>
</div>
</DialogContent>
</Dialog>
);
}
export function SingleImageLightbox({ image }: { image: { image_path: string; image_desc?: string } }) {
const [open, setOpen] = useState(false);
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<div className="cursor-pointer overflow-hidden rounded-lg shadow-md hover:opacity-90 transition-opacity">
<img
src={image.image_path}
alt={image.image_desc || 'Zdjęcie'}
className="w-full object-contain"
style={{ height: 'auto', maxHeight: '400px' }}
loading="lazy"
/>
</div>
</DialogTrigger>
<DialogContent className="sm:max-w-4xl p-0 bg-transparent border-none shadow-none [&>button]:hidden">
<DialogTitle className="sr-only">{image.image_desc || 'Zdjęcie'}</DialogTitle>
<DialogDescription className="sr-only">Przeglądarka zdjęć</DialogDescription>
<div className="fixed inset-0 bg-black/90 flex items-center justify-center">
<div className="relative">
<img
src={image.image_path}
alt={image.image_desc || 'Zdjęcie'}
className="max-h-[80vh] max-w-[90vw] object-contain rounded-lg"
style={{ aspectRatio: 'auto' }}
/>
<Button
variant="outline"
size="icon"
className="absolute top-4 right-4 z-10 bg-white/80 hover:bg-white dark:bg-neutral-800 dark:hover:bg-neutral-700"
onClick={() => setOpen(false)}
>
<X className="h-4 w-4" />
</Button>
</div>
</div>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,247 @@
import React, { useRef, useState } from "react";
import { ChevronUp, ChevronDown, Menu as MenuIcon, X as XIcon, ExternalLink } from 'lucide-react';
import { Link } from "@inertiajs/react";
export default function Nav() {
const [open, setOpen] = useState<number | null>(null);
const [mobileOpen, setMobileOpen] = useState(false);
const [menu, setMenu] = React.useState<any[]>([]);
const navRef = useRef<HTMLUListElement>(null);
React.useEffect(() => {
fetch('/api/navigation-items')
.then(res => res.json())
.then(data => setMenu(data));
const handler = (e: KeyboardEvent) => {
if (e.key === "Escape") setOpen(null);
};
window.addEventListener("keydown", handler);
return () => window.removeEventListener("keydown", handler);
}, []);
const handleKeyDown = (idx: number, hasDropdown: boolean) => (
e: React.KeyboardEvent
) => {
if (!hasDropdown) return;
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
setOpen(open === idx ? null : idx);
setTimeout(() => {
const dropdown = navRef.current?.querySelectorAll<HTMLAnchorElement>(
`[data-dropdown="${idx}"] a`
);
dropdown?.[0]?.focus();
}, 0);
}
if (e.key === "ArrowDown") {
e.preventDefault();
if (open !== idx) {
setOpen(idx);
setTimeout(() => {
const dropdown = navRef.current?.querySelectorAll<HTMLAnchorElement>(
`[data-dropdown="${idx}"] a`
);
dropdown?.[0]?.focus();
}, 0);
} else {
const dropdown = navRef.current?.querySelectorAll<HTMLAnchorElement>(
`[data-dropdown="${idx}"] a`
);
dropdown?.[0]?.focus();
}
}
if (e.key === "ArrowUp") {
e.preventDefault();
if (open !== idx) {
setOpen(idx);
setTimeout(() => {
const dropdown = navRef.current?.querySelectorAll<HTMLAnchorElement>(
`[data-dropdown="${idx}"] a`
);
dropdown?.[dropdown.length - 1]?.focus();
}, 0);
} else {
const dropdown = navRef.current?.querySelectorAll<HTMLAnchorElement>(
`[data-dropdown="${idx}"] a`
);
dropdown?.[dropdown.length - 1]?.focus();
}
}
if (e.key === "Tab") setOpen(null);
};
const handleDropdownKeyDown = (idx: number, i: number, arrLen: number) => (
e: React.KeyboardEvent
) => {
if (e.key === "ArrowDown") {
e.preventDefault();
const dropdown = navRef.current?.querySelectorAll<HTMLAnchorElement>(
`[data-dropdown="${idx}"] a`
);
const nextIdx = (i + 1) % (dropdown?.length || 1);
dropdown?.[nextIdx]?.focus();
}
if (e.key === "ArrowUp") {
e.preventDefault();
const dropdown = navRef.current?.querySelectorAll<HTMLAnchorElement>(
`[data-dropdown="${idx}"] a`
);
const prevIdx = (i - 1 + (dropdown?.length || 1)) % (dropdown?.length || 1);
dropdown?.[prevIdx]?.focus();
}
if (e.key === "Tab") setOpen(null);
if (e.key === "Escape") setOpen(null);
};
return (
<nav
className="w-full text-header-text bg-header-background border-t contrast:border-b border-neutral-300/20 dark:border-neutral-700/20 py-2 px-2 contrast:border-foreground/60 relative"
aria-label="Główna nawigacja"
>
{/* Hamburger mobile */}
<div className="flex items-center justify-between xl:hidden">
<span className="font-bold text-lg">MENU</span>
<button
className="xl:hidden p-2"
aria-label={mobileOpen ? "Zamknij menu" : "Otwórz menu"}
onClick={() => setMobileOpen((open) => !open)}
>
{mobileOpen ? <XIcon /> : <MenuIcon />}
</button>
</div>
{/* Desktop menu */}
<ul
className="hidden xl:flex flex-row gap-2 p-0 m-0 list-none"
ref={navRef}
>
{menu.map((item, idx) => (
<li key={item.id || idx} className="relative">
{item.type === 'article' && item.article ? (
<a
href={item.article.slug}
className="px-4 py-2 block rounded focus:outline focus:outline-2 focus:outline-neutral-300/20 dark:focus:outline-neutral-700/20 contrast:focus:outline-foreground"
tabIndex={0}
>
{item.article.title}
</a>
) : item.type === 'category' && item.articles && item.articles.length > 0 ? (
<>
<button
className="flex items-center justify-center px-4 py-2 block bg-transparent border-none cursor-pointer rounded focus:outline focus:outline-2 focus:outline-neutral-300/20 dark:focus:outline-neutral-700/20 contrast:focus:outline-foreground"
aria-haspopup="menu"
aria-expanded={open === idx}
aria-controls={`dropdown-${idx}`}
tabIndex={0}
onClick={() => setOpen(open === idx ? null : idx)}
onKeyDown={handleKeyDown(idx, true)}
>
{item.name || 'Kategoria'}
<div className="ml-1">{open === idx ? <ChevronUp className="h-4 w-4" /> : <ChevronDown className="h-4 w-4" />}</div>
</button>
{open === idx && (
<ul
id={`dropdown-${idx}`}
data-dropdown={idx}
role="menu"
className="absolute left-0 top-full min-w-[200px] bg-neutral-100 dark:bg-neutral-100 contrast:bg-black text-black dark:text-black contrast:text-foreground contrast:border contrast:border-foreground shadow-lg z-50 mt-1 rounded"
>
{item.articles.filter((d: any) => d.active).map((d: any, i: number) => (
<li key={d.id}>
{d.external ? (
<a
href={d.slug}
className="flex items-center justify-between px-4 py-2 hover:bg-gray-100 contrast:focus:bg-foreground contrast:focus:text-black focus:rounded focus:outline focus:outline-2 focus:outline-neutral-700/40 dark:focus:outline-neutral-800 contrast:focus:outline-foreground hover:contrast:bg-foreground hover:contrast:text-black"
tabIndex={0}
role="menuitem"
onKeyDown={handleDropdownKeyDown(idx, i, item.articles.filter((d: any) => d.active).length)}
onClick={() => setOpen(null)}
>
{d.title}
<ExternalLink className="h-4 w-4 ml-1" />
</a>
) : (
<Link
href={d.slug}
className="block px-4 py-2 hover:bg-gray-100 contrast:focus:bg-foreground contrast:focus:text-black focus:rounded focus:outline focus:outline-2 focus:outline-neutral-700/40 dark:focus:outline-neutral-800 contrast:focus:outline-foreground hover:contrast:bg-foreground hover:contrast:text-black"
tabIndex={0}
role="menuitem"
onKeyDown={handleDropdownKeyDown(idx, i, item.articles.filter((d: any) => d.active).length)}
onClick={() => setOpen(null)}
>
{d.title}
</Link>
)}
</li>
))}
</ul>
)}
</>
) : null}
</li>
))}
</ul>
{/* Mobile menu */}
<ul
className={`xl:hidden flex-col gap-2 p-0 m-0 list-none absolute left-0 w-full bg-header-background z-50 transition-all duration-200 ${mobileOpen ? "flex" : "hidden"}`}
>
{menu.map((item, idx) => (
<li key={item.id || idx} className="relative border-b border-neutral-300/20 dark:border-neutral-700/20">
{item.type === 'article' && item.article ? (
<a
href={item.article.slug}
className="px-4 py-3 block w-full text-left rounded focus:outline focus:outline-2 focus:outline-neutral-300/20 dark:focus:outline-neutral-700/20 contrast:focus:outline-foreground"
tabIndex={0}
onClick={() => setMobileOpen(false)}
>
{item.article.title}
</a>
) : item.type === 'category' && item.articles && item.articles.length > 0 ? (
<>
<button
className="flex items-center justify-between w-full px-4 py-3 bg-transparent border-none cursor-pointer rounded focus:outline focus:outline-2 focus:outline-neutral-300/20 dark:focus:outline-neutral-700/20 contrast:focus:outline-foreground"
onClick={() => setOpen(open === idx ? null : idx)}
tabIndex={0}
>
{item.name || 'Kategoria'}
<div className="ml-1">{open === idx ? <ChevronUp className="h-4 w-4" /> : <ChevronDown className="h-4 w-4" />}</div>
</button>
{open === idx && (
<ul
className="w-full bg-neutral-100 dark:bg-neutral-100 contrast:bg-black text-black dark:text-black contrast:text-foreground contrast:border contrast:border-foreground shadow-lg z-50 rounded"
>
{item.articles.filter((d: any) => d.active).map((d: any, i: number) => (
<li key={d.id}>
{d.external ? (
<a
href={d.slug}
className="flex items-center justify-between px-4 py-3 hover:bg-gray-100 contrast:focus:bg-foreground contrast:focus:text-black focus:rounded focus:outline focus:outline-2 focus:outline-neutral-700/40 dark:focus:outline-neutral-800 contrast:focus:outline-foreground hover:contrast:bg-foreground hover:contrast:text-black"
tabIndex={0}
onClick={() => { setOpen(null); setMobileOpen(false); }}
>
{d.title}
<ExternalLink className="h-4 w-4 ml-1" />
</a>
) : (
<Link
href={d.slug}
className="block px-4 py-3 hover:bg-gray-100 contrast:focus:bg-foreground contrast:focus:text-black focus:rounded focus:outline focus:outline-2 focus:outline-neutral-700/40 dark:focus:outline-neutral-800 contrast:focus:outline-foreground hover:contrast:bg-foreground hover:contrast:text-black"
tabIndex={0}
onClick={() => { setOpen(null); setMobileOpen(false); }}
>
{d.title}
</Link>
)}
</li>
))}
</ul>
)}
</>
) : null}
</li>
))}
</ul>
</nav>
);
}

View File

@@ -0,0 +1,136 @@
import React from 'react';
import {
Pagination as UIPagination,
PaginationContent,
PaginationEllipsis,
PaginationItem,
PaginationLink,
PaginationNext,
PaginationPrevious,
} from '@/components/ui/pagination';
interface PaginationProps {
currentPage: number;
lastPage: number;
onPageChange: (page: number) => void;
className?: string;
}
export function Pagination({ currentPage, lastPage, onPageChange, className = 'my-8' }: PaginationProps) {
return (
<UIPagination className={className}>
<PaginationContent>
{currentPage > 1 && (
<PaginationItem>
<PaginationPrevious
href="#"
onClick={(e) => {
e.preventDefault();
onPageChange(Math.max(currentPage - 1, 1));
}}
/>
</PaginationItem>
)}
{/* First page */}
<PaginationItem>
<PaginationLink
href="#"
isActive={currentPage === 1}
onClick={(e) => {
e.preventDefault();
onPageChange(1);
}}
>
1
</PaginationLink>
</PaginationItem>
{/* Ellipsis if needed */}
{currentPage > 3 && (
<PaginationItem>
<PaginationEllipsis />
</PaginationItem>
)}
{/* Page before current if not first or second page */}
{currentPage > 2 && (
<PaginationItem>
<PaginationLink
href="#"
onClick={(e) => {
e.preventDefault();
onPageChange(currentPage - 1);
}}
>
{currentPage - 1}
</PaginationLink>
</PaginationItem>
)}
{/* Current page if not first page */}
{currentPage !== 1 && currentPage !== lastPage && (
<PaginationItem>
<PaginationLink
href="#"
isActive={true}
onClick={(e) => e.preventDefault()}
>
{currentPage}
</PaginationLink>
</PaginationItem>
)}
{/* Page after current if not last or second-to-last page */}
{currentPage < lastPage - 1 && (
<PaginationItem>
<PaginationLink
href="#"
onClick={(e) => {
e.preventDefault();
onPageChange(currentPage + 1);
}}
>
{currentPage + 1}
</PaginationLink>
</PaginationItem>
)}
{/* Ellipsis if needed */}
{currentPage < lastPage - 2 && (
<PaginationItem>
<PaginationEllipsis />
</PaginationItem>
)}
{/* Last page if not first page */}
{lastPage > 1 && (
<PaginationItem>
<PaginationLink
href="#"
isActive={currentPage === lastPage}
onClick={(e) => {
e.preventDefault();
onPageChange(lastPage);
}}
>
{lastPage}
</PaginationLink>
</PaginationItem>
)}
{currentPage < lastPage && (
<PaginationItem>
<PaginationNext
href="#"
onClick={(e) => {
e.preventDefault();
onPageChange(Math.min(currentPage + 1, lastPage));
}}
/>
</PaginationItem>
)}
</PaginationContent>
</UIPagination>
);
}

View File

@@ -0,0 +1,234 @@
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Search, X } from 'lucide-react';
import { HTMLAttributes, useState, useEffect, useRef } from 'react';
import { cn } from '@/lib/utils';
import { Link } from "@inertiajs/react";
interface NavigationArticle {
id: number;
title: string;
slug: string;
external?: boolean;
active?: boolean;
}
interface NavigationCategory {
id: number;
name: string;
type: 'category';
articles: NavigationArticle[];
}
interface NavigationArticleItem {
id: number;
type: 'article';
article: NavigationArticle;
}
type NavigationItem = NavigationCategory | NavigationArticleItem;
export function SearchInput({ className = '', ...props }: HTMLAttributes<HTMLDivElement>) {
const [searchTerm, setSearchTerm] = useState('');
const [navigationItems, setNavigationItems] = useState<NavigationItem[]>([]);
const [searchResults, setSearchResults] = useState<Array<{title: string, slug: string, external?: boolean, category?: string}>>([]);
const [isSearching, setIsSearching] = useState(false);
const [showResults, setShowResults] = useState(false);
const searchRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
fetch('/api/navigation-items')
.then(res => res.json())
.then(data => setNavigationItems(data));
const handleClickOutside = (event: MouseEvent) => {
if (searchRef.current && !searchRef.current.contains(event.target as Node)) {
setShowResults(false);
}
};
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}, []);
const handleSearch = () => {
if (!searchTerm.trim()) {
setSearchResults([]);
setShowResults(false);
return;
}
setIsSearching(true);
const results: Array<{title: string, slug: string, external?: boolean, category?: string}> = [];
const searchTermLower = searchTerm.toLowerCase();
const matchingCategories = navigationItems.filter(
item => item.type === 'category' && item.name.toLowerCase().includes(searchTermLower)
);
matchingCategories.forEach(category => {
if (category.type === 'category' && category.articles) {
category.articles.forEach(article => {
if (article.active !== false) {
results.push({
title: article.title,
slug: article.slug,
external: article.external,
category: category.name
});
}
});
}
});
navigationItems.forEach(item => {
if (item.type === 'article' && item.article) {
if (item.article.title.toLowerCase().includes(searchTermLower)) {
const exists = results.some(r => r.slug === item.article.slug);
if (!exists) {
results.push({
title: item.article.title,
slug: item.article.slug,
external: item.article.external
});
}
}
} else if (item.type === 'category' && item.articles) {
if (!matchingCategories.includes(item)) {
item.articles.forEach(article => {
if (article.active !== false && article.title.toLowerCase().includes(searchTermLower)) {
const exists = results.some(r => r.slug === article.slug);
if (!exists) {
results.push({
title: article.title,
slug: article.slug,
external: article.external,
category: item.name
});
}
}
});
}
}
});
setSearchResults(results);
setShowResults(true);
setIsSearching(false);
};
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === 'Enter') {
e.preventDefault();
handleSearch();
}
};
const clearSearch = () => {
setSearchTerm('');
setSearchResults([]);
setShowResults(false);
inputRef.current?.focus();
};
return (
<div className={cn('relative w-full max-w-sm select-none', className)} {...props} ref={searchRef}>
<div className="flex items-center space-x-2">
<div className="relative flex-1">
<Input
type="search"
className="border-transparent bg-neutral-100 dark:bg-neutral-800 contrast:bg-primary text-black dark:text-white contrast:text-black contrast:placeholder-gray-700 h-full pr-8"
placeholder="Wyszukaj..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
onKeyDown={handleKeyDown}
onFocus={() => searchTerm && setShowResults(true)}
ref={inputRef}
/>
{searchTerm && (
<button
className="absolute right-2 top-1/2 transform -translate-y-1/2 text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200"
onClick={clearSearch}
aria-label="Wyczyść wyszukiwanie"
>
<X className="h-4 w-4" />
</button>
)}
</div>
<Button
type="submit"
aria-label="Szukaj"
className="bg-red-700 hover:bg-red-700/60 dark:bg-red-800 dark:hover:bg-red-700 contrast:bg-primary hover:contrast:bg-primary/60 text-white dark:text-white contrast:text-black h-full flex items-center justify-center"
onClick={handleSearch}
disabled={isSearching}
>
<Search className="h-5 w-5 font-bold" />
</Button>
</div>
{showResults && (
<div className="absolute left-0 right-0 top-full mt-1 bg-white dark:bg-neutral-800 contrast:bg-foreground shadow-lg rounded-md z-50 max-h-[300px] overflow-y-auto">
{searchResults.length > 0 ? (
<div className="py-2">
{(() => {
const groupedResults: Record<string, typeof searchResults> = {};
searchResults.forEach(result => {
if (result.category) {
if (!groupedResults[result.category]) {
groupedResults[result.category] = [];
}
groupedResults[result.category].push(result);
}
});
const uncategorized = searchResults.filter(result => !result.category);
if (uncategorized.length > 0) {
groupedResults['Inne'] = uncategorized;
}
return Object.entries(groupedResults).map(([category, results], groupIndex) => (
<div key={groupIndex} className="mb-2">
<div className="px-4 py-1 font-semibold text-sm text-gray-500 dark:text-gray-400 bg-gray-50 dark:bg-neutral-900 contrast:bg-black contrast:text-black contrast:bg-foreground">
{category}
</div>
<ul>
{results.map((result, index) => (
<li key={index} className="px-4 py-2 hover:bg-gray-100 dark:hover:bg-neutral-700 contrast:hover:bg-black">
{result.external ? (
<a
href={result.slug}
className="block w-full text-black dark:text-white contrast:hover:text-foreground flex items-center"
target="_blank"
rel="noopener noreferrer"
onClick={() => setShowResults(false)}
>
{result.title}
</a>
) : (
<Link
href={result.slug}
className="block w-full text-black dark:text-white contrast:hover:text-foreground"
onClick={() => setShowResults(false)}
>
{result.title}
</Link>
)}
</li>
))}
</ul>
</div>
));
})()}
</div>
) : (
<div className="p-4 text-center text-gray-500 dark:text-gray-400">
Nie znaleziono wyników dla {searchTerm}
</div>
)}
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,66 @@
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const alertVariants = cva(
"relative w-full rounded-lg border px-4 py-3 text-sm grid has-[>svg]:grid-cols-[calc(var(--spacing)*4)_1fr] grid-cols-[0_1fr] has-[>svg]:gap-x-3 gap-y-0.5 items-start [&>svg]:size-4 [&>svg]:translate-y-0.5 [&>svg]:text-current",
{
variants: {
variant: {
default: "bg-background text-foreground",
destructive:
"text-destructive-foreground [&>svg]:text-current *:data-[slot=alert-description]:text-destructive-foreground/80",
},
},
defaultVariants: {
variant: "default",
},
}
)
function Alert({
className,
variant,
...props
}: React.ComponentProps<"div"> & VariantProps<typeof alertVariants>) {
return (
<div
data-slot="alert"
role="alert"
className={cn(alertVariants({ variant }), className)}
{...props}
/>
)
}
function AlertTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-title"
className={cn(
"col-start-2 line-clamp-1 min-h-4 font-medium tracking-tight",
className
)}
{...props}
/>
)
}
function AlertDescription({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-description"
className={cn(
"text-muted-foreground col-start-2 grid justify-items-start gap-1 text-sm [&_p]:leading-relaxed",
className
)}
{...props}
/>
)
}
export { Alert, AlertTitle, AlertDescription }

View File

@@ -0,0 +1,51 @@
import * as React from "react"
import * as AvatarPrimitive from "@radix-ui/react-avatar"
import { cn } from "@/lib/utils"
function Avatar({
className,
...props
}: React.ComponentProps<typeof AvatarPrimitive.Root>) {
return (
<AvatarPrimitive.Root
data-slot="avatar"
className={cn(
"relative flex size-8 shrink-0 overflow-hidden rounded-full",
className
)}
{...props}
/>
)
}
function AvatarImage({
className,
...props
}: React.ComponentProps<typeof AvatarPrimitive.Image>) {
return (
<AvatarPrimitive.Image
data-slot="avatar-image"
className={cn("aspect-square size-full", className)}
{...props}
/>
)
}
function AvatarFallback({
className,
...props
}: React.ComponentProps<typeof AvatarPrimitive.Fallback>) {
return (
<AvatarPrimitive.Fallback
data-slot="avatar-fallback"
className={cn(
"bg-muted flex size-full items-center justify-center rounded-full",
className
)}
{...props}
/>
)
}
export { Avatar, AvatarImage, AvatarFallback }

View File

@@ -0,0 +1,46 @@
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 badgeVariants = cva(
"inline-flex items-center justify-center rounded-md border px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-[color,box-shadow] overflow-auto",
{
variants: {
variant: {
default:
"border-transparent bg-primary text-primary-foreground [a&]:hover:bg-primary/90",
secondary:
"border-transparent bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90",
destructive:
"border-transparent bg-destructive text-white [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40",
outline:
"text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
},
},
defaultVariants: {
variant: "default",
},
}
)
function Badge({
className,
variant,
asChild = false,
...props
}: React.ComponentProps<"span"> &
VariantProps<typeof badgeVariants> & { asChild?: boolean }) {
const Comp = asChild ? Slot : "span"
return (
<Comp
data-slot="badge"
className={cn(badgeVariants({ variant }), className)}
{...props}
/>
)
}
export { Badge, badgeVariants }

View File

@@ -0,0 +1,109 @@
import * as React from "react"
import { Slot } from "@radix-ui/react-slot"
import { ChevronRight, MoreHorizontal } from "lucide-react"
import { cn } from "@/lib/utils"
function Breadcrumb({ ...props }: React.ComponentProps<"nav">) {
return <nav aria-label="breadcrumb" data-slot="breadcrumb" {...props} />
}
function BreadcrumbList({ className, ...props }: React.ComponentProps<"ol">) {
return (
<ol
data-slot="breadcrumb-list"
className={cn(
"text-muted-foreground flex flex-wrap items-center gap-1.5 text-sm break-words sm:gap-2.5",
className
)}
{...props}
/>
)
}
function BreadcrumbItem({ className, ...props }: React.ComponentProps<"li">) {
return (
<li
data-slot="breadcrumb-item"
className={cn("inline-flex items-center gap-1.5", className)}
{...props}
/>
)
}
function BreadcrumbLink({
asChild,
className,
...props
}: React.ComponentProps<"a"> & {
asChild?: boolean
}) {
const Comp = asChild ? Slot : "a"
return (
<Comp
data-slot="breadcrumb-link"
className={cn("hover:text-foreground transition-colors", className)}
{...props}
/>
)
}
function BreadcrumbPage({ className, ...props }: React.ComponentProps<"span">) {
return (
<span
data-slot="breadcrumb-page"
role="link"
aria-disabled="true"
aria-current="page"
className={cn("text-foreground font-normal", className)}
{...props}
/>
)
}
function BreadcrumbSeparator({
children,
className,
...props
}: React.ComponentProps<"li">) {
return (
<li
data-slot="breadcrumb-separator"
role="presentation"
aria-hidden="true"
className={cn("[&>svg]:size-3.5", className)}
{...props}
>
{children ?? <ChevronRight />}
</li>
)
}
function BreadcrumbEllipsis({
className,
...props
}: React.ComponentProps<"span">) {
return (
<span
data-slot="breadcrumb-ellipsis"
role="presentation"
aria-hidden="true"
className={cn("flex size-9 items-center justify-center", className)}
{...props}
>
<MoreHorizontal className="size-4" />
<span className="sr-only">More</span>
</span>
)
}
export {
Breadcrumb,
BreadcrumbList,
BreadcrumbItem,
BreadcrumbLink,
BreadcrumbPage,
BreadcrumbSeparator,
BreadcrumbEllipsis,
}

View File

@@ -0,0 +1,59 @@
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 transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
{
variants: {
variant: {
default:
"bg-primary text-primary-foreground shadow-xs hover:bg-primary/90",
destructive:
"bg-destructive text-white shadow-xs hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
outline:
"border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50",
secondary:
"bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80",
ghost:
"hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default: "h-9 px-4 py-2 has-[>svg]:px-3",
sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5",
lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
icon: "size-9",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
function Button({
className,
variant,
size,
asChild = false,
...props
}: React.ComponentProps<"button"> &
VariantProps<typeof buttonVariants> & {
asChild?: boolean
}) {
const Comp = asChild ? Slot : "button"
return (
<Comp
data-slot="button"
className={cn(buttonVariants({ variant, size, className }))}
{...props}
/>
)
}
export { Button, buttonVariants }

View File

@@ -0,0 +1,68 @@
import * as React from "react"
import { cn } from "@/lib/utils"
function Card({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card"
className={cn(
"bg-card text-card-foreground flex flex-col gap-6 rounded-xl border py-6 shadow-sm",
className
)}
{...props}
/>
)
}
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-header"
className={cn("flex flex-col gap-1.5 px-6", className)}
{...props}
/>
)
}
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-title"
className={cn("leading-none font-semibold", className)}
{...props}
/>
)
}
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-description"
className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
)
}
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-content"
className={cn("px-6", className)}
{...props}
/>
)
}
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-footer"
className={cn("flex items-center px-6", className)}
{...props}
/>
)
}
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent }

View File

@@ -0,0 +1,239 @@
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<typeof useEmblaCarousel>
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<typeof useEmblaCarousel>[0]
api: ReturnType<typeof useEmblaCarousel>[1]
scrollPrev: () => void
scrollNext: () => void
canScrollPrev: boolean
canScrollNext: boolean
} & CarouselProps
const CarouselContext = React.createContext<CarouselContextProps | null>(null)
function useCarousel() {
const context = React.useContext(CarouselContext)
if (!context) {
throw new Error("useCarousel must be used within a <Carousel />")
}
return context
}
function Carousel({
orientation = "horizontal",
opts,
setApi,
plugins,
className,
children,
...props
}: React.ComponentProps<"div"> & CarouselProps) {
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<HTMLDivElement>) => {
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 (
<CarouselContext.Provider
value={{
carouselRef,
api: api,
opts,
orientation:
orientation || (opts?.axis === "y" ? "vertical" : "horizontal"),
scrollPrev,
scrollNext,
canScrollPrev,
canScrollNext,
}}
>
<div
onKeyDownCapture={handleKeyDown}
className={cn("relative", className)}
role="region"
aria-roledescription="carousel"
data-slot="carousel"
{...props}
>
{children}
</div>
</CarouselContext.Provider>
)
}
function CarouselContent({ className, ...props }: React.ComponentProps<"div">) {
const { carouselRef, orientation } = useCarousel()
return (
<div
ref={carouselRef}
className="overflow-hidden"
data-slot="carousel-content"
>
<div
className={cn(
"flex",
orientation === "horizontal" ? "-ml-4" : "-mt-4 flex-col",
className
)}
{...props}
/>
</div>
)
}
function CarouselItem({ className, ...props }: React.ComponentProps<"div">) {
const { orientation } = useCarousel()
return (
<div
role="group"
aria-roledescription="slide"
data-slot="carousel-item"
className={cn(
"min-w-0 shrink-0 grow-0 basis-full",
orientation === "horizontal" ? "pl-4" : "pt-4",
className
)}
{...props}
/>
)
}
function CarouselPrevious({
className,
variant = "outline",
size = "icon",
...props
}: React.ComponentProps<typeof Button>) {
const { orientation, scrollPrev, canScrollPrev } = useCarousel()
return (
<Button
data-slot="carousel-previous"
variant={variant}
size={size}
className={cn(
"absolute size-8 rounded-full",
orientation === "horizontal"
? "top-1/2 -left-12 -translate-y-1/2"
: "-top-12 left-1/2 -translate-x-1/2 rotate-90",
className
)}
disabled={!canScrollPrev}
onClick={scrollPrev}
{...props}
>
<ArrowLeft />
<span className="sr-only">Previous slide</span>
</Button>
)
}
function CarouselNext({
className,
variant = "outline",
size = "icon",
...props
}: React.ComponentProps<typeof Button>) {
const { orientation, scrollNext, canScrollNext } = useCarousel()
return (
<Button
data-slot="carousel-next"
variant={variant}
size={size}
className={cn(
"absolute size-8 rounded-full",
orientation === "horizontal"
? "top-1/2 -right-12 -translate-y-1/2"
: "-bottom-12 left-1/2 -translate-x-1/2 rotate-90",
className
)}
disabled={!canScrollNext}
onClick={scrollNext}
{...props}
>
<ArrowRight />
<span className="sr-only">Next slide</span>
</Button>
)
}
export {
type CarouselApi,
Carousel,
CarouselContent,
CarouselItem,
CarouselPrevious,
CarouselNext,
}

View File

@@ -0,0 +1,30 @@
import * as React from "react"
import * as CheckboxPrimitive from "@radix-ui/react-checkbox"
import { CheckIcon } from "lucide-react"
import { cn } from "@/lib/utils"
function Checkbox({
className,
...props
}: React.ComponentProps<typeof CheckboxPrimitive.Root>) {
return (
<CheckboxPrimitive.Root
data-slot="checkbox"
className={cn(
"peer border-input data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground data-[state=checked]:border-primary focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive size-4 shrink-0 rounded-[4px] border shadow-xs transition-shadow outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
className
)}
{...props}
>
<CheckboxPrimitive.Indicator
data-slot="checkbox-indicator"
className="flex items-center justify-center text-current transition-none"
>
<CheckIcon className="size-3.5" />
</CheckboxPrimitive.Indicator>
</CheckboxPrimitive.Root>
)
}
export { Checkbox }

View File

@@ -0,0 +1,31 @@
import * as CollapsiblePrimitive from "@radix-ui/react-collapsible"
function Collapsible({
...props
}: React.ComponentProps<typeof CollapsiblePrimitive.Root>) {
return <CollapsiblePrimitive.Root data-slot="collapsible" {...props} />
}
function CollapsibleTrigger({
...props
}: React.ComponentProps<typeof CollapsiblePrimitive.CollapsibleTrigger>) {
return (
<CollapsiblePrimitive.CollapsibleTrigger
data-slot="collapsible-trigger"
{...props}
/>
)
}
function CollapsibleContent({
...props
}: React.ComponentProps<typeof CollapsiblePrimitive.CollapsibleContent>) {
return (
<CollapsiblePrimitive.CollapsibleContent
data-slot="collapsible-content"
{...props}
/>
)
}
export { Collapsible, CollapsibleTrigger, CollapsibleContent }

View File

@@ -0,0 +1,133 @@
import * as React from "react"
import * as DialogPrimitive from "@radix-ui/react-dialog"
import { XIcon } from "lucide-react"
import { cn } from "@/lib/utils"
function Dialog({
...props
}: React.ComponentProps<typeof DialogPrimitive.Root>) {
return <DialogPrimitive.Root data-slot="dialog" {...props} />
}
function DialogTrigger({
...props
}: React.ComponentProps<typeof DialogPrimitive.Trigger>) {
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />
}
function DialogPortal({
...props
}: React.ComponentProps<typeof DialogPrimitive.Portal>) {
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />
}
function DialogClose({
...props
}: React.ComponentProps<typeof DialogPrimitive.Close>) {
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />
}
function DialogOverlay({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Overlay>) {
return (
<DialogPrimitive.Overlay
data-slot="dialog-overlay"
className={cn(
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/80",
className
)}
{...props}
/>
)
}
function DialogContent({
className,
children,
...props
}: React.ComponentProps<typeof DialogPrimitive.Content>) {
return (
<DialogPortal data-slot="dialog-portal">
<DialogOverlay />
<DialogPrimitive.Content
data-slot="dialog-content"
className={cn(
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg",
className
)}
{...props}
>
{children}
<DialogPrimitive.Close className="ring-offset-background focus:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4">
<XIcon />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
</DialogPrimitive.Content>
</DialogPortal>
)
}
function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="dialog-header"
className={cn("flex flex-col gap-2 text-center sm:text-left", className)}
{...props}
/>
)
}
function DialogFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="dialog-footer"
className={cn(
"flex flex-col-reverse gap-2 sm:flex-row sm:justify-end",
className
)}
{...props}
/>
)
}
function DialogTitle({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Title>) {
return (
<DialogPrimitive.Title
data-slot="dialog-title"
className={cn("text-lg leading-none font-semibold", className)}
{...props}
/>
)
}
function DialogDescription({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Description>) {
return (
<DialogPrimitive.Description
data-slot="dialog-description"
className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
)
}
export {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogOverlay,
DialogPortal,
DialogTitle,
DialogTrigger,
}

View File

@@ -0,0 +1,255 @@
import * as React from "react"
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu"
import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react"
import { cn } from "@/lib/utils"
function DropdownMenu({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Root>) {
return <DropdownMenuPrimitive.Root data-slot="dropdown-menu" {...props} />
}
function DropdownMenuPortal({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Portal>) {
return (
<DropdownMenuPrimitive.Portal data-slot="dropdown-menu-portal" {...props} />
)
}
function DropdownMenuTrigger({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Trigger>) {
return (
<DropdownMenuPrimitive.Trigger
data-slot="dropdown-menu-trigger"
{...props}
/>
)
}
function DropdownMenuContent({
className,
sideOffset = 4,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Content>) {
return (
<DropdownMenuPrimitive.Portal>
<DropdownMenuPrimitive.Content
data-slot="dropdown-menu-content"
sideOffset={sideOffset}
className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[8rem] overflow-hidden rounded-md border p-1 shadow-md",
className
)}
{...props}
/>
</DropdownMenuPrimitive.Portal>
)
}
function DropdownMenuGroup({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Group>) {
return (
<DropdownMenuPrimitive.Group data-slot="dropdown-menu-group" {...props} />
)
}
function DropdownMenuItem({
className,
inset,
variant = "default",
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Item> & {
inset?: boolean
variant?: "default" | "destructive"
}) {
return (
<DropdownMenuPrimitive.Item
data-slot="dropdown-menu-item"
data-inset={inset}
data-variant={variant}
className={cn(
"focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive-foreground data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/40 data-[variant=destructive]:focus:text-destructive-foreground data-[variant=destructive]:*:[svg]:!text-destructive-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
/>
)
}
function DropdownMenuCheckboxItem({
className,
children,
checked,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.CheckboxItem>) {
return (
<DropdownMenuPrimitive.CheckboxItem
data-slot="dropdown-menu-checkbox-item"
className={cn(
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
checked={checked}
{...props}
>
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<CheckIcon className="size-4" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.CheckboxItem>
)
}
function DropdownMenuRadioGroup({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioGroup>) {
return (
<DropdownMenuPrimitive.RadioGroup
data-slot="dropdown-menu-radio-group"
{...props}
/>
)
}
function DropdownMenuRadioItem({
className,
children,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioItem>) {
return (
<DropdownMenuPrimitive.RadioItem
data-slot="dropdown-menu-radio-item"
className={cn(
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<CircleIcon className="size-2 fill-current" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.RadioItem>
)
}
function DropdownMenuLabel({
className,
inset,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Label> & {
inset?: boolean
}) {
return (
<DropdownMenuPrimitive.Label
data-slot="dropdown-menu-label"
data-inset={inset}
className={cn(
"px-2 py-1.5 text-sm font-medium data-[inset]:pl-8",
className
)}
{...props}
/>
)
}
function DropdownMenuSeparator({
className,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Separator>) {
return (
<DropdownMenuPrimitive.Separator
data-slot="dropdown-menu-separator"
className={cn("bg-border -mx-1 my-1 h-px", className)}
{...props}
/>
)
}
function DropdownMenuShortcut({
className,
...props
}: React.ComponentProps<"span">) {
return (
<span
data-slot="dropdown-menu-shortcut"
className={cn(
"text-muted-foreground ml-auto text-xs tracking-widest",
className
)}
{...props}
/>
)
}
function DropdownMenuSub({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Sub>) {
return <DropdownMenuPrimitive.Sub data-slot="dropdown-menu-sub" {...props} />
}
function DropdownMenuSubTrigger({
className,
inset,
children,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubTrigger> & {
inset?: boolean
}) {
return (
<DropdownMenuPrimitive.SubTrigger
data-slot="dropdown-menu-sub-trigger"
data-inset={inset}
className={cn(
"focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground flex cursor-default items-center rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[inset]:pl-8",
className
)}
{...props}
>
{children}
<ChevronRightIcon className="ml-auto size-4" />
</DropdownMenuPrimitive.SubTrigger>
)
}
function DropdownMenuSubContent({
className,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubContent>) {
return (
<DropdownMenuPrimitive.SubContent
data-slot="dropdown-menu-sub-content"
className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[8rem] overflow-hidden rounded-md border p-1 shadow-lg",
className
)}
{...props}
/>
)
}
export {
DropdownMenu,
DropdownMenuPortal,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuLabel,
DropdownMenuItem,
DropdownMenuCheckboxItem,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuSub,
DropdownMenuSubTrigger,
DropdownMenuSubContent,
}

View File

@@ -0,0 +1,14 @@
import { LucideIcon } from 'lucide-react';
interface IconProps {
iconNode?: LucideIcon | null;
className?: string;
}
export function Icon({ iconNode: IconComponent, className }: IconProps) {
if (!IconComponent) {
return null;
}
return <IconComponent className={className} />;
}

View File

@@ -0,0 +1,21 @@
import * as React from "react"
import { cn } from "@/lib/utils"
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
return (
<input
type={type}
data-slot="input"
className={cn(
"border-input file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground flex h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
className
)}
{...props}
/>
)
}
export { Input }

View File

@@ -0,0 +1,22 @@
import * as React from "react"
import * as LabelPrimitive from "@radix-ui/react-label"
import { cn } from "@/lib/utils"
function Label({
className,
...props
}: React.ComponentProps<typeof LabelPrimitive.Root>) {
return (
<LabelPrimitive.Root
data-slot="label"
className={cn(
"text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50",
className
)}
{...props}
/>
)
}
export { Label }

View File

@@ -0,0 +1,274 @@
import * as React from "react"
import * as MenubarPrimitive from "@radix-ui/react-menubar"
import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react"
import { cn } from "@/lib/utils"
function Menubar({
className,
...props
}: React.ComponentProps<typeof MenubarPrimitive.Root>) {
return (
<MenubarPrimitive.Root
data-slot="menubar"
className={cn(
"bg-background flex h-9 items-center gap-1 rounded-md border p-1 shadow-xs",
className
)}
{...props}
/>
)
}
function MenubarMenu({
...props
}: React.ComponentProps<typeof MenubarPrimitive.Menu>) {
return <MenubarPrimitive.Menu data-slot="menubar-menu" {...props} />
}
function MenubarGroup({
...props
}: React.ComponentProps<typeof MenubarPrimitive.Group>) {
return <MenubarPrimitive.Group data-slot="menubar-group" {...props} />
}
function MenubarPortal({
...props
}: React.ComponentProps<typeof MenubarPrimitive.Portal>) {
return <MenubarPrimitive.Portal data-slot="menubar-portal" {...props} />
}
function MenubarRadioGroup({
...props
}: React.ComponentProps<typeof MenubarPrimitive.RadioGroup>) {
return (
<MenubarPrimitive.RadioGroup data-slot="menubar-radio-group" {...props} />
)
}
function MenubarTrigger({
className,
...props
}: React.ComponentProps<typeof MenubarPrimitive.Trigger>) {
return (
<MenubarPrimitive.Trigger
data-slot="menubar-trigger"
className={cn(
"focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground flex items-center rounded-sm px-2 py-1 text-sm font-medium outline-hidden select-none",
className
)}
{...props}
/>
)
}
function MenubarContent({
className,
align = "start",
alignOffset = -4,
sideOffset = 8,
...props
}: React.ComponentProps<typeof MenubarPrimitive.Content>) {
return (
<MenubarPortal>
<MenubarPrimitive.Content
data-slot="menubar-content"
align={align}
alignOffset={alignOffset}
sideOffset={sideOffset}
className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[12rem] origin-(--radix-menubar-content-transform-origin) overflow-hidden rounded-md border p-1 shadow-md",
className
)}
{...props}
/>
</MenubarPortal>
)
}
function MenubarItem({
className,
inset,
variant = "default",
...props
}: React.ComponentProps<typeof MenubarPrimitive.Item> & {
inset?: boolean
variant?: "default" | "destructive"
}) {
return (
<MenubarPrimitive.Item
data-slot="menubar-item"
data-inset={inset}
data-variant={variant}
className={cn(
"focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:!text-destructive [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
/>
)
}
function MenubarCheckboxItem({
className,
children,
checked,
...props
}: React.ComponentProps<typeof MenubarPrimitive.CheckboxItem>) {
return (
<MenubarPrimitive.CheckboxItem
data-slot="menubar-checkbox-item"
className={cn(
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-xs py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
checked={checked}
{...props}
>
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
<MenubarPrimitive.ItemIndicator>
<CheckIcon className="size-4" />
</MenubarPrimitive.ItemIndicator>
</span>
{children}
</MenubarPrimitive.CheckboxItem>
)
}
function MenubarRadioItem({
className,
children,
...props
}: React.ComponentProps<typeof MenubarPrimitive.RadioItem>) {
return (
<MenubarPrimitive.RadioItem
data-slot="menubar-radio-item"
className={cn(
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-xs py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
<MenubarPrimitive.ItemIndicator>
<CircleIcon className="size-2 fill-current" />
</MenubarPrimitive.ItemIndicator>
</span>
{children}
</MenubarPrimitive.RadioItem>
)
}
function MenubarLabel({
className,
inset,
...props
}: React.ComponentProps<typeof MenubarPrimitive.Label> & {
inset?: boolean
}) {
return (
<MenubarPrimitive.Label
data-slot="menubar-label"
data-inset={inset}
className={cn(
"px-2 py-1.5 text-sm font-medium data-[inset]:pl-8",
className
)}
{...props}
/>
)
}
function MenubarSeparator({
className,
...props
}: React.ComponentProps<typeof MenubarPrimitive.Separator>) {
return (
<MenubarPrimitive.Separator
data-slot="menubar-separator"
className={cn("bg-border -mx-1 my-1 h-px", className)}
{...props}
/>
)
}
function MenubarShortcut({
className,
...props
}: React.ComponentProps<"span">) {
return (
<span
data-slot="menubar-shortcut"
className={cn(
"text-muted-foreground ml-auto text-xs tracking-widest",
className
)}
{...props}
/>
)
}
function MenubarSub({
...props
}: React.ComponentProps<typeof MenubarPrimitive.Sub>) {
return <MenubarPrimitive.Sub data-slot="menubar-sub" {...props} />
}
function MenubarSubTrigger({
className,
inset,
children,
...props
}: React.ComponentProps<typeof MenubarPrimitive.SubTrigger> & {
inset?: boolean
}) {
return (
<MenubarPrimitive.SubTrigger
data-slot="menubar-sub-trigger"
data-inset={inset}
className={cn(
"focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground flex cursor-default items-center rounded-sm px-2 py-1.5 text-sm outline-none select-none data-[inset]:pl-8",
className
)}
{...props}
>
{children}
<ChevronRightIcon className="ml-auto h-4 w-4" />
</MenubarPrimitive.SubTrigger>
)
}
function MenubarSubContent({
className,
...props
}: React.ComponentProps<typeof MenubarPrimitive.SubContent>) {
return (
<MenubarPrimitive.SubContent
data-slot="menubar-sub-content"
className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[8rem] origin-(--radix-menubar-content-transform-origin) overflow-hidden rounded-md border p-1 shadow-lg",
className
)}
{...props}
/>
)
}
export {
Menubar,
MenubarPortal,
MenubarMenu,
MenubarTrigger,
MenubarContent,
MenubarGroup,
MenubarSeparator,
MenubarLabel,
MenubarItem,
MenubarShortcut,
MenubarCheckboxItem,
MenubarRadioGroup,
MenubarRadioItem,
MenubarSub,
MenubarSubTrigger,
MenubarSubContent,
}

View File

@@ -0,0 +1,168 @@
import * as React from "react"
import * as NavigationMenuPrimitive from "@radix-ui/react-navigation-menu"
import { cva } from "class-variance-authority"
import { ChevronDownIcon } from "lucide-react"
import { cn } from "@/lib/utils"
function NavigationMenu({
className,
children,
viewport = true,
...props
}: React.ComponentProps<typeof NavigationMenuPrimitive.Root> & {
viewport?: boolean
}) {
return (
<NavigationMenuPrimitive.Root
data-slot="navigation-menu"
data-viewport={viewport}
className={cn(
"group/navigation-menu relative flex max-w-max flex-1 items-center justify-center",
className
)}
{...props}
>
{children}
{viewport && <NavigationMenuViewport />}
</NavigationMenuPrimitive.Root>
)
}
function NavigationMenuList({
className,
...props
}: React.ComponentProps<typeof NavigationMenuPrimitive.List>) {
return (
<NavigationMenuPrimitive.List
data-slot="navigation-menu-list"
className={cn(
"group flex flex-1 list-none items-center justify-center gap-1",
className
)}
{...props}
/>
)
}
function NavigationMenuItem({
className,
...props
}: React.ComponentProps<typeof NavigationMenuPrimitive.Item>) {
return (
<NavigationMenuPrimitive.Item
data-slot="navigation-menu-item"
className={cn("relative", className)}
{...props}
/>
)
}
const navigationMenuTriggerStyle = cva(
"group inline-flex h-9 w-max items-center justify-center rounded-md bg-background px-4 py-2 text-sm font-medium hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground disabled:pointer-events-none disabled:opacity-50 data-[state=open]:hover:bg-accent data-[state=open]:text-accent-foreground data-[state=open]:focus:bg-accent data-[state=open]:bg-accent/50 focus-visible:ring-ring/50 outline-none transition-[color,box-shadow] focus-visible:ring-[3px] focus-visible:outline-1"
)
function NavigationMenuTrigger({
className,
children,
...props
}: React.ComponentProps<typeof NavigationMenuPrimitive.Trigger>) {
return (
<NavigationMenuPrimitive.Trigger
data-slot="navigation-menu-trigger"
className={cn(navigationMenuTriggerStyle(), "group", className)}
{...props}
>
{children}{" "}
<ChevronDownIcon
className="relative top-[1px] ml-1 size-3 transition duration-300 group-data-[state=open]:rotate-180"
aria-hidden="true"
/>
</NavigationMenuPrimitive.Trigger>
)
}
function NavigationMenuContent({
className,
...props
}: React.ComponentProps<typeof NavigationMenuPrimitive.Content>) {
return (
<NavigationMenuPrimitive.Content
data-slot="navigation-menu-content"
className={cn(
"data-[motion^=from-]:animate-in data-[motion^=to-]:animate-out data-[motion^=from-]:fade-in data-[motion^=to-]:fade-out data-[motion=from-end]:slide-in-from-right-52 data-[motion=from-start]:slide-in-from-left-52 data-[motion=to-end]:slide-out-to-right-52 data-[motion=to-start]:slide-out-to-left-52 top-0 left-0 w-full p-2 pr-2.5 md:absolute md:w-auto",
"group-data-[viewport=false]/navigation-menu:bg-popover group-data-[viewport=false]/navigation-menu:text-popover-foreground group-data-[viewport=false]/navigation-menu:data-[state=open]:animate-in group-data-[viewport=false]/navigation-menu:data-[state=closed]:animate-out group-data-[viewport=false]/navigation-menu:data-[state=closed]:zoom-out-95 group-data-[viewport=false]/navigation-menu:data-[state=open]:zoom-in-95 group-data-[viewport=false]/navigation-menu:data-[state=open]:fade-in-0 group-data-[viewport=false]/navigation-menu:data-[state=closed]:fade-out-0 group-data-[viewport=false]/navigation-menu:top-full group-data-[viewport=false]/navigation-menu:mt-1.5 group-data-[viewport=false]/navigation-menu:overflow-hidden group-data-[viewport=false]/navigation-menu:rounded-md group-data-[viewport=false]/navigation-menu:border group-data-[viewport=false]/navigation-menu:shadow group-data-[viewport=false]/navigation-menu:duration-200 **:data-[slot=navigation-menu-link]:focus:ring-0 **:data-[slot=navigation-menu-link]:focus:outline-none",
className
)}
{...props}
/>
)
}
function NavigationMenuViewport({
className,
...props
}: React.ComponentProps<typeof NavigationMenuPrimitive.Viewport>) {
return (
<div
className={cn(
"absolute top-full left-0 isolate z-50 flex justify-center"
)}
>
<NavigationMenuPrimitive.Viewport
data-slot="navigation-menu-viewport"
className={cn(
"origin-top-center bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-90 relative mt-1.5 h-[var(--radix-navigation-menu-viewport-height)] w-full overflow-hidden rounded-md border shadow md:w-[var(--radix-navigation-menu-viewport-width)]",
className
)}
{...props}
/>
</div>
)
}
function NavigationMenuLink({
className,
...props
}: React.ComponentProps<typeof NavigationMenuPrimitive.Link>) {
return (
<NavigationMenuPrimitive.Link
data-slot="navigation-menu-link"
className={cn(
"data-[active=true]:focus:bg-accent data-[active=true]:hover:bg-accent data-[active=true]:bg-accent/50 data-[active=true]:text-accent-foreground hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground focus-visible:ring-ring/50 [&_svg:not([class*='text-'])]:text-muted-foreground flex flex-col gap-1 rounded-sm p-2 text-sm transition-all outline-none focus-visible:ring-[3px] focus-visible:outline-1 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
/>
)
}
function NavigationMenuIndicator({
className,
...props
}: React.ComponentProps<typeof NavigationMenuPrimitive.Indicator>) {
return (
<NavigationMenuPrimitive.Indicator
data-slot="navigation-menu-indicator"
className={cn(
"data-[state=visible]:animate-in data-[state=hidden]:animate-out data-[state=hidden]:fade-out data-[state=visible]:fade-in top-full z-[1] flex h-1.5 items-end justify-center overflow-hidden",
className
)}
{...props}
>
<div className="bg-border relative top-[60%] h-2 w-2 rotate-45 rounded-tl-sm shadow-md" />
</NavigationMenuPrimitive.Indicator>
)
}
export {
NavigationMenu,
NavigationMenuList,
NavigationMenuItem,
NavigationMenuContent,
NavigationMenuTrigger,
NavigationMenuLink,
NavigationMenuIndicator,
NavigationMenuViewport,
navigationMenuTriggerStyle,
}

View File

@@ -0,0 +1,127 @@
import * as React from "react"
import {
ChevronLeftIcon,
ChevronRightIcon,
MoreHorizontalIcon,
} from "lucide-react"
import { cn } from "@/lib/utils"
import { Button, buttonVariants } from "@/components/ui/button"
function Pagination({ className, ...props }: React.ComponentProps<"nav">) {
return (
<nav
role="navigation"
aria-label="pagination"
data-slot="pagination"
className={cn("mx-auto flex w-full justify-center", className)}
{...props}
/>
)
}
function PaginationContent({
className,
...props
}: React.ComponentProps<"ul">) {
return (
<ul
data-slot="pagination-content"
className={cn("flex flex-row items-center gap-1", className)}
{...props}
/>
)
}
function PaginationItem({ ...props }: React.ComponentProps<"li">) {
return <li data-slot="pagination-item" {...props} />
}
type PaginationLinkProps = {
isActive?: boolean
} & Pick<React.ComponentProps<typeof Button>, "size"> &
React.ComponentProps<"a">
function PaginationLink({
className,
isActive,
size = "icon",
...props
}: PaginationLinkProps) {
return (
<a
aria-current={isActive ? "page" : undefined}
data-slot="pagination-link"
data-active={isActive}
className={cn(
buttonVariants({
variant: isActive ? "outline" : "ghost",
size,
}),
className
)}
{...props}
/>
)
}
function PaginationPrevious({
className,
...props
}: React.ComponentProps<typeof PaginationLink>) {
return (
<PaginationLink
aria-label="Go to previous page"
size="default"
className={cn("gap-1 px-2.5 sm:pl-2.5", className)}
{...props}
>
<ChevronLeftIcon />
<span className="hidden sm:block">Poprzednia</span>
</PaginationLink>
)
}
function PaginationNext({
className,
...props
}: React.ComponentProps<typeof PaginationLink>) {
return (
<PaginationLink
aria-label="Go to next page"
size="default"
className={cn("gap-1 px-2.5 sm:pr-2.5", className)}
{...props}
>
<span className="hidden sm:block">Następna</span>
<ChevronRightIcon />
</PaginationLink>
)
}
function PaginationEllipsis({
className,
...props
}: React.ComponentProps<"span">) {
return (
<span
aria-hidden
data-slot="pagination-ellipsis"
className={cn("flex size-9 items-center justify-center", className)}
{...props}
>
<MoreHorizontalIcon className="size-4" />
<span className="sr-only">Więcej stron</span>
</span>
)
}
export {
Pagination,
PaginationContent,
PaginationLink,
PaginationItem,
PaginationPrevious,
PaginationNext,
PaginationEllipsis,
}

View File

@@ -0,0 +1,20 @@
import { useId } from 'react';
interface PlaceholderPatternProps {
className?: string;
}
export function PlaceholderPattern({ className }: PlaceholderPatternProps) {
const patternId = useId();
return (
<svg className={className} fill="none">
<defs>
<pattern id={patternId} x="0" y="0" width="10" height="10" patternUnits="userSpaceOnUse">
<path d="M-3 13 15-5M-5 5l18-18M-1 21 17 3"></path>
</pattern>
</defs>
<rect stroke="none" fill={`url(#${patternId})`} width="100%" height="100%"></rect>
</svg>
);
}

View File

@@ -0,0 +1,179 @@
import * as React from "react"
import * as SelectPrimitive from "@radix-ui/react-select"
import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from "lucide-react"
import { cn } from "@/lib/utils"
function Select({
...props
}: React.ComponentProps<typeof SelectPrimitive.Root>) {
return <SelectPrimitive.Root data-slot="select" {...props} />
}
function SelectGroup({
...props
}: React.ComponentProps<typeof SelectPrimitive.Group>) {
return <SelectPrimitive.Group data-slot="select-group" {...props} />
}
function SelectValue({
...props
}: React.ComponentProps<typeof SelectPrimitive.Value>) {
return <SelectPrimitive.Value data-slot="select-value" {...props} />
}
function SelectTrigger({
className,
children,
...props
}: React.ComponentProps<typeof SelectPrimitive.Trigger>) {
return (
<SelectPrimitive.Trigger
data-slot="select-trigger"
className={cn(
"border-input data-[placeholder]:text-muted-foreground [&_svg:not([class*='text-'])]:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive flex h-9 w-full items-center justify-between rounded-md border bg-transparent px-3 py-2 text-sm shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 [&>span]:line-clamp-1",
className
)}
{...props}
>
{children}
<SelectPrimitive.Icon asChild>
<ChevronDownIcon className="size-4 opacity-50" />
</SelectPrimitive.Icon>
</SelectPrimitive.Trigger>
)
}
function SelectContent({
className,
children,
position = "popper",
...props
}: React.ComponentProps<typeof SelectPrimitive.Content>) {
return (
<SelectPrimitive.Portal>
<SelectPrimitive.Content
data-slot="select-content"
className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 relative z-50 max-h-96 min-w-[8rem] overflow-hidden rounded-md border shadow-md",
position === "popper" &&
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
className
)}
position={position}
{...props}
>
<SelectScrollUpButton />
<SelectPrimitive.Viewport
className={cn(
"p-1",
position === "popper" &&
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)] scroll-my-1"
)}
>
{children}
</SelectPrimitive.Viewport>
<SelectScrollDownButton />
</SelectPrimitive.Content>
</SelectPrimitive.Portal>
)
}
function SelectLabel({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.Label>) {
return (
<SelectPrimitive.Label
data-slot="select-label"
className={cn("px-2 py-1.5 text-sm font-medium", className)}
{...props}
/>
)
}
function SelectItem({
className,
children,
...props
}: React.ComponentProps<typeof SelectPrimitive.Item>) {
return (
<SelectPrimitive.Item
data-slot="select-item"
className={cn(
"focus:bg-accent focus:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex w-full cursor-default items-center gap-2 rounded-sm py-1.5 pr-8 pl-2 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
className
)}
{...props}
>
<span className="absolute right-2 flex size-3.5 items-center justify-center">
<SelectPrimitive.ItemIndicator>
<CheckIcon className="size-4" />
</SelectPrimitive.ItemIndicator>
</span>
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
</SelectPrimitive.Item>
)
}
function SelectSeparator({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.Separator>) {
return (
<SelectPrimitive.Separator
data-slot="select-separator"
className={cn("bg-border pointer-events-none -mx-1 my-1 h-px", className)}
{...props}
/>
)
}
function SelectScrollUpButton({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.ScrollUpButton>) {
return (
<SelectPrimitive.ScrollUpButton
data-slot="select-scroll-up-button"
className={cn(
"flex cursor-default items-center justify-center py-1",
className
)}
{...props}
>
<ChevronUpIcon className="size-4" />
</SelectPrimitive.ScrollUpButton>
)
}
function SelectScrollDownButton({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.ScrollDownButton>) {
return (
<SelectPrimitive.ScrollDownButton
data-slot="select-scroll-down-button"
className={cn(
"flex cursor-default items-center justify-center py-1",
className
)}
{...props}
>
<ChevronDownIcon className="size-4" />
</SelectPrimitive.ScrollDownButton>
)
}
export {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectLabel,
SelectScrollDownButton,
SelectScrollUpButton,
SelectSeparator,
SelectTrigger,
SelectValue,
}

View File

@@ -0,0 +1,26 @@
import * as React from "react"
import * as SeparatorPrimitive from "@radix-ui/react-separator"
import { cn } from "@/lib/utils"
function Separator({
className,
orientation = "horizontal",
decorative = true,
...props
}: React.ComponentProps<typeof SeparatorPrimitive.Root>) {
return (
<SeparatorPrimitive.Root
data-slot="separator-root"
decorative={decorative}
orientation={orientation}
className={cn(
"bg-border shrink-0 data-[orientation=horizontal]:h-px data-[orientation=horizontal]:w-full data-[orientation=vertical]:h-full data-[orientation=vertical]:w-px",
className
)}
{...props}
/>
)
}
export { Separator }

View File

@@ -0,0 +1,137 @@
import * as React from "react"
import * as SheetPrimitive from "@radix-ui/react-dialog"
import { XIcon } from "lucide-react"
import { cn } from "@/lib/utils"
function Sheet({ ...props }: React.ComponentProps<typeof SheetPrimitive.Root>) {
return <SheetPrimitive.Root data-slot="sheet" {...props} />
}
function SheetTrigger({
...props
}: React.ComponentProps<typeof SheetPrimitive.Trigger>) {
return <SheetPrimitive.Trigger data-slot="sheet-trigger" {...props} />
}
function SheetClose({
...props
}: React.ComponentProps<typeof SheetPrimitive.Close>) {
return <SheetPrimitive.Close data-slot="sheet-close" {...props} />
}
function SheetPortal({
...props
}: React.ComponentProps<typeof SheetPrimitive.Portal>) {
return <SheetPrimitive.Portal data-slot="sheet-portal" {...props} />
}
function SheetOverlay({
className,
...props
}: React.ComponentProps<typeof SheetPrimitive.Overlay>) {
return (
<SheetPrimitive.Overlay
data-slot="sheet-overlay"
className={cn(
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/80",
className
)}
{...props}
/>
)
}
function SheetContent({
className,
children,
side = "right",
...props
}: React.ComponentProps<typeof SheetPrimitive.Content> & {
side?: "top" | "right" | "bottom" | "left"
}) {
return (
<SheetPortal>
<SheetOverlay />
<SheetPrimitive.Content
data-slot="sheet-content"
className={cn(
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out fixed z-50 flex flex-col gap-4 shadow-lg transition ease-in-out data-[state=closed]:duration-300 data-[state=open]:duration-500",
side === "right" &&
"data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right inset-y-0 right-0 h-full w-3/4 border-l sm:max-w-sm",
side === "left" &&
"data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left inset-y-0 left-0 h-full w-3/4 border-r sm:max-w-sm",
side === "top" &&
"data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top inset-x-0 top-0 h-auto border-b",
side === "bottom" &&
"data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom inset-x-0 bottom-0 h-auto border-t",
className
)}
{...props}
>
{children}
<SheetPrimitive.Close className="ring-offset-background focus:ring-ring data-[state=open]:bg-secondary absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none">
<XIcon className="size-4" />
<span className="sr-only">Close</span>
</SheetPrimitive.Close>
</SheetPrimitive.Content>
</SheetPortal>
)
}
function SheetHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sheet-header"
className={cn("flex flex-col gap-1.5 p-4", className)}
{...props}
/>
)
}
function SheetFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sheet-footer"
className={cn("mt-auto flex flex-col gap-2 p-4", className)}
{...props}
/>
)
}
function SheetTitle({
className,
...props
}: React.ComponentProps<typeof SheetPrimitive.Title>) {
return (
<SheetPrimitive.Title
data-slot="sheet-title"
className={cn("text-foreground font-semibold", className)}
{...props}
/>
)
}
function SheetDescription({
className,
...props
}: React.ComponentProps<typeof SheetPrimitive.Description>) {
return (
<SheetPrimitive.Description
data-slot="sheet-description"
className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
)
}
export {
Sheet,
SheetTrigger,
SheetClose,
SheetContent,
SheetHeader,
SheetFooter,
SheetTitle,
SheetDescription,
}

View File

@@ -0,0 +1,721 @@
import * as React from "react"
import { Slot } from "@radix-ui/react-slot"
import { VariantProps, cva } from "class-variance-authority"
import { PanelLeftIcon } from "lucide-react"
import { useIsMobile } from "@/hooks/use-mobile"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Separator } from "@/components/ui/separator"
import {
Sheet,
SheetContent,
SheetDescription,
SheetHeader,
SheetTitle,
} from "@/components/ui/sheet"
import { Skeleton } from "@/components/ui/skeleton"
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip"
const SIDEBAR_COOKIE_NAME = "sidebar_state"
const SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 7
const SIDEBAR_WIDTH = "16rem"
const SIDEBAR_WIDTH_MOBILE = "18rem"
const SIDEBAR_WIDTH_ICON = "3rem"
const SIDEBAR_KEYBOARD_SHORTCUT = "b"
type SidebarContext = {
state: "expanded" | "collapsed"
open: boolean
setOpen: (open: boolean) => void
openMobile: boolean
setOpenMobile: (open: boolean) => void
isMobile: boolean
toggleSidebar: () => void
}
const SidebarContext = React.createContext<SidebarContext | null>(null)
function useSidebar() {
const context = React.useContext(SidebarContext)
if (!context) {
throw new Error("useSidebar must be used within a SidebarProvider.")
}
return context
}
function SidebarProvider({
defaultOpen = true,
open: openProp,
onOpenChange: setOpenProp,
className,
style,
children,
...props
}: React.ComponentProps<"div"> & {
defaultOpen?: boolean
open?: boolean
onOpenChange?: (open: boolean) => void
}) {
const isMobile = useIsMobile()
const [openMobile, setOpenMobile] = React.useState(false)
// This is the internal state of the sidebar.
// We use openProp and setOpenProp for control from outside the component.
const [_open, _setOpen] = React.useState(defaultOpen)
const open = openProp ?? _open
const setOpen = React.useCallback(
(value: boolean | ((value: boolean) => boolean)) => {
const openState = typeof value === "function" ? value(open) : value
if (setOpenProp) {
setOpenProp(openState)
} else {
_setOpen(openState)
}
// This sets the cookie to keep the sidebar state.
document.cookie = `${SIDEBAR_COOKIE_NAME}=${openState}; path=/; max-age=${SIDEBAR_COOKIE_MAX_AGE}`
},
[setOpenProp, open]
)
// Helper to toggle the sidebar.
const toggleSidebar = React.useCallback(() => {
return isMobile ? setOpenMobile((open) => !open) : setOpen((open) => !open)
}, [isMobile, setOpen, setOpenMobile])
// Adds a keyboard shortcut to toggle the sidebar.
React.useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
if (
event.key === SIDEBAR_KEYBOARD_SHORTCUT &&
(event.metaKey || event.ctrlKey)
) {
event.preventDefault()
toggleSidebar()
}
}
window.addEventListener("keydown", handleKeyDown)
return () => window.removeEventListener("keydown", handleKeyDown)
}, [toggleSidebar])
// We add a state so that we can do data-state="expanded" or "collapsed".
// This makes it easier to style the sidebar with Tailwind classes.
const state = open ? "expanded" : "collapsed"
const contextValue = React.useMemo<SidebarContext>(
() => ({
state,
open,
setOpen,
isMobile,
openMobile,
setOpenMobile,
toggleSidebar,
}),
[state, open, setOpen, isMobile, openMobile, setOpenMobile, toggleSidebar]
)
return (
<SidebarContext.Provider value={contextValue}>
<TooltipProvider delayDuration={0}>
<div
data-slot="sidebar-wrapper"
style={
{
"--sidebar-width": SIDEBAR_WIDTH,
"--sidebar-width-icon": SIDEBAR_WIDTH_ICON,
...style,
} as React.CSSProperties
}
className={cn(
"group/sidebar-wrapper has-data-[variant=inset]:bg-sidebar flex min-h-svh w-full",
className
)}
{...props}
>
{children}
</div>
</TooltipProvider>
</SidebarContext.Provider>
)
}
function Sidebar({
side = "left",
variant = "sidebar",
collapsible = "offcanvas",
className,
children,
...props
}: React.ComponentProps<"div"> & {
side?: "left" | "right"
variant?: "sidebar" | "floating" | "inset"
collapsible?: "offcanvas" | "icon" | "none"
}) {
const { isMobile, state, openMobile, setOpenMobile } = useSidebar()
if (collapsible === "none") {
return (
<div
data-slot="sidebar"
className={cn(
"bg-sidebar text-sidebar-foreground flex h-full w-(--sidebar-width) flex-col",
className
)}
{...props}
>
{children}
</div>
)
}
if (isMobile) {
return (
<Sheet open={openMobile} onOpenChange={setOpenMobile} {...props}>
<SheetHeader className="sr-only">
<SheetTitle>Sidebar</SheetTitle>
<SheetDescription>Displays the mobile sidebar.</SheetDescription>
</SheetHeader>
<SheetContent
data-sidebar="sidebar"
data-slot="sidebar"
data-mobile="true"
className="bg-sidebar text-sidebar-foreground w-(--sidebar-width) p-0 [&>button]:hidden"
style={
{
"--sidebar-width": SIDEBAR_WIDTH_MOBILE,
} as React.CSSProperties
}
side={side}
>
<div className="flex h-full w-full flex-col">{children}</div>
</SheetContent>
</Sheet>
)
}
return (
<div
className="group peer text-sidebar-foreground hidden md:block"
data-state={state}
data-collapsible={state === "collapsed" ? collapsible : ""}
data-variant={variant}
data-side={side}
data-slot="sidebar"
>
{/* This is what handles the sidebar gap on desktop */}
<div
className={cn(
"relative h-svh w-(--sidebar-width) bg-transparent transition-[width] duration-200 ease-linear",
"group-data-[collapsible=offcanvas]:w-0",
"group-data-[side=right]:rotate-180",
variant === "floating" || variant === "inset"
? "group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+(--spacing(4)))]"
: "group-data-[collapsible=icon]:w-(--sidebar-width-icon)"
)}
/>
<div
className={cn(
"fixed inset-y-0 z-10 hidden h-svh w-(--sidebar-width) transition-[left,right,width] duration-200 ease-linear md:flex",
side === "left"
? "left-0 group-data-[collapsible=offcanvas]:left-[calc(var(--sidebar-width)*-1)]"
: "right-0 group-data-[collapsible=offcanvas]:right-[calc(var(--sidebar-width)*-1)]",
// Adjust the padding for floating and inset variants.
variant === "floating" || variant === "inset"
? "p-2 group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+(--spacing(4))+2px)]"
: "group-data-[collapsible=icon]:w-(--sidebar-width-icon) group-data-[side=left]:border-r group-data-[side=right]:border-l",
className
)}
{...props}
>
<div
data-sidebar="sidebar"
className="bg-sidebar group-data-[variant=floating]:border-sidebar-border flex h-full w-full flex-col group-data-[variant=floating]:rounded-lg group-data-[variant=floating]:border group-data-[variant=floating]:shadow-sm"
>
{children}
</div>
</div>
</div>
)
}
function SidebarTrigger({
className,
onClick,
...props
}: React.ComponentProps<typeof Button>) {
const { toggleSidebar } = useSidebar()
return (
<Button
data-sidebar="trigger"
data-slot="sidebar-trigger"
variant="ghost"
size="icon"
className={cn("h-7 w-7", className)}
onClick={(event) => {
onClick?.(event)
toggleSidebar()
}}
{...props}
>
<PanelLeftIcon />
<span className="sr-only">Toggle Sidebar</span>
</Button>
)
}
function SidebarRail({ className, ...props }: React.ComponentProps<"button">) {
const { toggleSidebar } = useSidebar()
return (
<button
data-sidebar="rail"
data-slot="sidebar-rail"
aria-label="Toggle Sidebar"
tabIndex={-1}
onClick={toggleSidebar}
title="Toggle Sidebar"
className={cn(
"hover:after:bg-sidebar-border absolute inset-y-0 z-20 hidden w-4 -translate-x-1/2 transition-all ease-linear group-data-[side=left]:-right-4 group-data-[side=right]:left-0 after:absolute after:inset-y-0 after:left-1/2 after:w-[2px] sm:flex",
"in-data-[side=left]:cursor-w-resize in-data-[side=right]:cursor-e-resize",
"[[data-side=left][data-state=collapsed]_&]:cursor-e-resize [[data-side=right][data-state=collapsed]_&]:cursor-w-resize",
"hover:group-data-[collapsible=offcanvas]:bg-sidebar group-data-[collapsible=offcanvas]:translate-x-0 group-data-[collapsible=offcanvas]:after:left-full",
"[[data-side=left][data-collapsible=offcanvas]_&]:-right-2",
"[[data-side=right][data-collapsible=offcanvas]_&]:-left-2",
className
)}
{...props}
/>
)
}
function SidebarInset({ className, ...props }: React.ComponentProps<"main">) {
return (
<main
data-slot="sidebar-inset"
className={cn(
"bg-background relative flex max-w-full min-h-svh flex-1 flex-col",
"peer-data-[variant=inset]:min-h-[calc(100svh-(--spacing(4)))] md:peer-data-[variant=inset]:m-2 md:peer-data-[variant=inset]:ml-0 md:peer-data-[variant=inset]:rounded-xl md:peer-data-[variant=inset]:shadow-sm md:peer-data-[variant=inset]:peer-data-[state=collapsed]:ml-0",
className
)}
{...props}
/>
)
}
function SidebarInput({
className,
...props
}: React.ComponentProps<typeof Input>) {
return (
<Input
data-slot="sidebar-input"
data-sidebar="input"
className={cn("bg-background h-8 w-full shadow-none", className)}
{...props}
/>
)
}
function SidebarHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sidebar-header"
data-sidebar="header"
className={cn("flex flex-col gap-2 p-2", className)}
{...props}
/>
)
}
function SidebarFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sidebar-footer"
data-sidebar="footer"
className={cn("flex flex-col gap-2 p-2", className)}
{...props}
/>
)
}
function SidebarSeparator({
className,
...props
}: React.ComponentProps<typeof Separator>) {
return (
<Separator
data-slot="sidebar-separator"
data-sidebar="separator"
className={cn("bg-sidebar-border mx-2 w-auto", className)}
{...props}
/>
)
}
function SidebarContent({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sidebar-content"
data-sidebar="content"
className={cn(
"flex min-h-0 flex-1 flex-col gap-2 overflow-auto group-data-[collapsible=icon]:overflow-hidden",
className
)}
{...props}
/>
)
}
function SidebarGroup({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sidebar-group"
data-sidebar="group"
className={cn("relative flex w-full min-w-0 flex-col p-2", className)}
{...props}
/>
)
}
function SidebarGroupLabel({
className,
asChild = false,
...props
}: React.ComponentProps<"div"> & { asChild?: boolean }) {
const Comp = asChild ? Slot : "div"
return (
<Comp
data-slot="sidebar-group-label"
data-sidebar="group-label"
className={cn(
"text-sidebar-foreground/70 ring-sidebar-ring flex h-8 shrink-0 items-center rounded-md px-2 text-xs font-medium outline-hidden transition-[margin,opacity] duration-200 ease-linear focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
"group-data-[collapsible=icon]:-mt-8 group-data-[collapsible=icon]:opacity-0",
className
)}
{...props}
/>
)
}
function SidebarGroupAction({
className,
asChild = false,
...props
}: React.ComponentProps<"button"> & { asChild?: boolean }) {
const Comp = asChild ? Slot : "button"
return (
<Comp
data-slot="sidebar-group-action"
data-sidebar="group-action"
className={cn(
"text-sidebar-foreground ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground absolute top-3.5 right-3 flex aspect-square w-5 items-center justify-center rounded-md p-0 outline-hidden transition-transform focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
// Increases the hit area of the button on mobile.
"after:absolute after:-inset-2 md:after:hidden",
"group-data-[collapsible=icon]:hidden",
className
)}
{...props}
/>
)
}
function SidebarGroupContent({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="sidebar-group-content"
data-sidebar="group-content"
className={cn("w-full text-sm", className)}
{...props}
/>
)
}
function SidebarMenu({ className, ...props }: React.ComponentProps<"ul">) {
return (
<ul
data-slot="sidebar-menu"
data-sidebar="menu"
className={cn("flex w-full min-w-0 flex-col gap-1", className)}
{...props}
/>
)
}
function SidebarMenuItem({ className, ...props }: React.ComponentProps<"li">) {
return (
<li
data-slot="sidebar-menu-item"
data-sidebar="menu-item"
className={cn("group/menu-item relative", className)}
{...props}
/>
)
}
const sidebarMenuButtonVariants = cva(
"peer/menu-button flex w-full items-center gap-2 overflow-hidden rounded-md p-2 text-left text-sm outline-hidden ring-sidebar-ring transition-[width,height,padding] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 group-has-data-[sidebar=menu-action]/menu-item:pr-8 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-[active=true]:bg-sidebar-accent data-[active=true]:font-medium data-[active=true]:text-sidebar-accent-foreground data-[state=open]:hover:bg-sidebar-accent data-[state=open]:hover:text-sidebar-accent-foreground group-data-[collapsible=icon]:size-8! group-data-[collapsible=icon]:p-2! [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0",
{
variants: {
variant: {
default: "hover:bg-sidebar-accent hover:text-sidebar-accent-foreground",
outline:
"bg-background shadow-[0_0_0_1px_hsl(var(--sidebar-border))] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground hover:shadow-[0_0_0_1px_hsl(var(--sidebar-accent))]",
},
size: {
default: "h-8 text-sm",
sm: "h-7 text-xs",
lg: "h-12 text-sm group-data-[collapsible=icon]:p-0!",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
function SidebarMenuButton({
asChild = false,
isActive = false,
variant = "default",
size = "default",
tooltip,
className,
...props
}: React.ComponentProps<"button"> & {
asChild?: boolean
isActive?: boolean
tooltip?: string | React.ComponentProps<typeof TooltipContent>
} & VariantProps<typeof sidebarMenuButtonVariants>) {
const Comp = asChild ? Slot : "button"
const { isMobile, state } = useSidebar()
const button = (
<Comp
data-slot="sidebar-menu-button"
data-sidebar="menu-button"
data-size={size}
data-active={isActive}
className={cn(sidebarMenuButtonVariants({ variant, size }), className)}
{...props}
/>
)
if (!tooltip) {
return button
}
if (typeof tooltip === "string") {
tooltip = {
children: tooltip,
}
}
return (
<Tooltip>
<TooltipTrigger asChild>{button}</TooltipTrigger>
<TooltipContent
side="right"
align="center"
hidden={state !== "collapsed" || isMobile}
{...tooltip}
/>
</Tooltip>
)
}
function SidebarMenuAction({
className,
asChild = false,
showOnHover = false,
...props
}: React.ComponentProps<"button"> & {
asChild?: boolean
showOnHover?: boolean
}) {
const Comp = asChild ? Slot : "button"
return (
<Comp
data-slot="sidebar-menu-action"
data-sidebar="menu-action"
className={cn(
"text-sidebar-foreground ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground peer-hover/menu-button:text-sidebar-accent-foreground absolute top-1.5 right-1 flex aspect-square w-5 items-center justify-center rounded-md p-0 outline-hidden transition-transform focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
// Increases the hit area of the button on mobile.
"after:absolute after:-inset-2 md:after:hidden",
"peer-data-[size=sm]/menu-button:top-1",
"peer-data-[size=default]/menu-button:top-1.5",
"peer-data-[size=lg]/menu-button:top-2.5",
"group-data-[collapsible=icon]:hidden",
showOnHover &&
"peer-data-[active=true]/menu-button:text-sidebar-accent-foreground group-focus-within/menu-item:opacity-100 group-hover/menu-item:opacity-100 data-[state=open]:opacity-100 md:opacity-0",
className
)}
{...props}
/>
)
}
function SidebarMenuBadge({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="sidebar-menu-badge"
data-sidebar="menu-badge"
className={cn(
"text-sidebar-foreground pointer-events-none absolute right-1 flex h-5 min-w-5 items-center justify-center rounded-md px-1 text-xs font-medium tabular-nums select-none",
"peer-hover/menu-button:text-sidebar-accent-foreground peer-data-[active=true]/menu-button:text-sidebar-accent-foreground",
"peer-data-[size=sm]/menu-button:top-1",
"peer-data-[size=default]/menu-button:top-1.5",
"peer-data-[size=lg]/menu-button:top-2.5",
"group-data-[collapsible=icon]:hidden",
className
)}
{...props}
/>
)
}
function SidebarMenuSkeleton({
className,
showIcon = false,
...props
}: React.ComponentProps<"div"> & {
showIcon?: boolean
}) {
// Random width between 50 to 90%.
const width = React.useMemo(() => {
return `${Math.floor(Math.random() * 40) + 50}%`
}, [])
return (
<div
data-slot="sidebar-menu-skeleton"
data-sidebar="menu-skeleton"
className={cn("flex h-8 items-center gap-2 rounded-md px-2", className)}
{...props}
>
{showIcon && (
<Skeleton
className="size-4 rounded-md"
data-sidebar="menu-skeleton-icon"
/>
)}
<Skeleton
className="h-4 max-w-(--skeleton-width) flex-1"
data-sidebar="menu-skeleton-text"
style={
{
"--skeleton-width": width,
} as React.CSSProperties
}
/>
</div>
)
}
function SidebarMenuSub({ className, ...props }: React.ComponentProps<"ul">) {
return (
<ul
data-slot="sidebar-menu-sub"
data-sidebar="menu-sub"
className={cn(
"border-sidebar-border mx-3.5 flex min-w-0 translate-x-px flex-col gap-1 border-l px-2.5 py-0.5",
"group-data-[collapsible=icon]:hidden",
className
)}
{...props}
/>
)
}
function SidebarMenuSubItem({
className,
...props
}: React.ComponentProps<"li">) {
return (
<li
data-slot="sidebar-menu-sub-item"
data-sidebar="menu-sub-item"
className={cn("group/menu-sub-item relative", className)}
{...props}
/>
)
}
function SidebarMenuSubButton({
asChild = false,
size = "md",
isActive = false,
className,
...props
}: React.ComponentProps<"a"> & {
asChild?: boolean
size?: "sm" | "md"
isActive?: boolean
}) {
const Comp = asChild ? Slot : "a"
return (
<Comp
data-slot="sidebar-menu-sub-button"
data-sidebar="menu-sub-button"
data-size={size}
data-active={isActive}
className={cn(
"text-sidebar-foreground ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground active:bg-sidebar-accent active:text-sidebar-accent-foreground [&>svg]:text-sidebar-accent-foreground flex h-7 min-w-0 -translate-x-px items-center gap-2 overflow-hidden rounded-md px-2 outline-hidden focus-visible:ring-2 disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0",
"data-[active=true]:bg-sidebar-accent data-[active=true]:text-sidebar-accent-foreground",
size === "sm" && "text-xs",
size === "md" && "text-sm",
"group-data-[collapsible=icon]:hidden",
className
)}
{...props}
/>
)
}
export {
Sidebar,
SidebarContent,
SidebarFooter,
SidebarGroup,
SidebarGroupAction,
SidebarGroupContent,
SidebarGroupLabel,
SidebarHeader,
SidebarInput,
SidebarInset,
SidebarMenu,
SidebarMenuAction,
SidebarMenuBadge,
SidebarMenuButton,
SidebarMenuItem,
SidebarMenuSkeleton,
SidebarMenuSub,
SidebarMenuSubButton,
SidebarMenuSubItem,
SidebarProvider,
SidebarRail,
SidebarSeparator,
SidebarTrigger,
useSidebar,
}

View File

@@ -0,0 +1,13 @@
import { cn } from "@/lib/utils"
function Skeleton({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="skeleton"
className={cn("bg-primary/10 animate-pulse rounded-md", className)}
{...props}
/>
)
}
export { Skeleton }

View File

@@ -0,0 +1,18 @@
import * as React from "react"
import { cn } from "@/lib/utils"
function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
return (
<textarea
data-slot="textarea"
className={cn(
"border-input placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 flex field-sizing-content min-h-16 w-full rounded-md border bg-transparent px-3 py-2 text-base shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
className
)}
{...props}
/>
)
}
export { Textarea }

View File

@@ -0,0 +1,71 @@
import * as React from "react"
import * as ToggleGroupPrimitive from "@radix-ui/react-toggle-group"
import { type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
import { toggleVariants } from "@/components/ui/toggle"
const ToggleGroupContext = React.createContext<
VariantProps<typeof toggleVariants>
>({
size: "default",
variant: "default",
})
function ToggleGroup({
className,
variant,
size,
children,
...props
}: React.ComponentProps<typeof ToggleGroupPrimitive.Root> &
VariantProps<typeof toggleVariants>) {
return (
<ToggleGroupPrimitive.Root
data-slot="toggle-group"
data-variant={variant}
data-size={size}
className={cn(
"group/toggle-group flex items-center rounded-md data-[variant=outline]:shadow-xs",
className
)}
{...props}
>
<ToggleGroupContext.Provider value={{ variant, size }}>
{children}
</ToggleGroupContext.Provider>
</ToggleGroupPrimitive.Root>
)
}
function ToggleGroupItem({
className,
children,
variant,
size,
...props
}: React.ComponentProps<typeof ToggleGroupPrimitive.Item> &
VariantProps<typeof toggleVariants>) {
const context = React.useContext(ToggleGroupContext)
return (
<ToggleGroupPrimitive.Item
data-slot="toggle-group-item"
data-variant={context.variant || variant}
data-size={context.size || size}
className={cn(
toggleVariants({
variant: context.variant || variant,
size: context.size || size,
}),
"min-w-0 shrink-0 rounded-none shadow-none first:rounded-l-md last:rounded-r-md focus:z-10 focus-visible:z-10 data-[variant=outline]:border-l-0 data-[variant=outline]:first:border-l",
className
)}
{...props}
>
{children}
</ToggleGroupPrimitive.Item>
)
}
export { ToggleGroup, ToggleGroupItem }

View File

@@ -0,0 +1,45 @@
import * as React from "react"
import * as TogglePrimitive from "@radix-ui/react-toggle"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const toggleVariants = cva(
"inline-flex items-center justify-center gap-2 rounded-md text-sm font-medium hover:bg-muted hover:text-muted-foreground disabled:pointer-events-none disabled:opacity-50 data-[state=on]:bg-accent data-[state=on]:text-accent-foreground [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 [&_svg]:shrink-0 focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] outline-none transition-[color,box-shadow] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
{
variants: {
variant: {
default: "bg-transparent",
outline:
"border border-input bg-transparent shadow-xs hover:bg-accent hover:text-accent-foreground",
},
size: {
default: "h-9 px-2 min-w-9",
sm: "h-8 px-1.5 min-w-8",
lg: "h-10 px-2.5 min-w-10",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
function Toggle({
className,
variant,
size,
...props
}: React.ComponentProps<typeof TogglePrimitive.Root> &
VariantProps<typeof toggleVariants>) {
return (
<TogglePrimitive.Root
data-slot="toggle"
className={cn(toggleVariants({ variant, size, className }))}
{...props}
/>
)
}
export { Toggle, toggleVariants }

View File

@@ -0,0 +1,59 @@
import * as React from "react"
import * as TooltipPrimitive from "@radix-ui/react-tooltip"
import { cn } from "@/lib/utils"
function TooltipProvider({
delayDuration = 0,
...props
}: React.ComponentProps<typeof TooltipPrimitive.Provider>) {
return (
<TooltipPrimitive.Provider
data-slot="tooltip-provider"
delayDuration={delayDuration}
{...props}
/>
)
}
function Tooltip({
...props
}: React.ComponentProps<typeof TooltipPrimitive.Root>) {
return (
<TooltipProvider>
<TooltipPrimitive.Root data-slot="tooltip" {...props} />
</TooltipProvider>
)
}
function TooltipTrigger({
...props
}: React.ComponentProps<typeof TooltipPrimitive.Trigger>) {
return <TooltipPrimitive.Trigger data-slot="tooltip-trigger" {...props} />
}
function TooltipContent({
className,
sideOffset = 4,
children,
...props
}: React.ComponentProps<typeof TooltipPrimitive.Content>) {
return (
<TooltipPrimitive.Portal>
<TooltipPrimitive.Content
data-slot="tooltip-content"
sideOffset={sideOffset}
className={cn(
"bg-primary text-primary-foreground animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 max-w-sm rounded-md px-3 py-1.5 text-xs",
className
)}
{...props}
>
{children}
<TooltipPrimitive.Arrow className="bg-primary fill-primary z-50 size-2.5 translate-y-[calc(-50%_-_2px)] rotate-45 rounded-[2px]" />
</TooltipPrimitive.Content>
</TooltipPrimitive.Portal>
)
}
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }

View File

@@ -0,0 +1,84 @@
import { useCallback, useEffect, useState } from 'react';
export type Appearance = 'light' | 'dark' | 'contrast';
const prefersDark = () => {
if (typeof window === 'undefined') {
return false;
}
return window.matchMedia('(prefers-color-scheme: dark)').matches;
};
const setCookie = (name: string, value: string, days = 365) => {
if (typeof document === 'undefined') {
return;
}
const maxAge = days * 24 * 60 * 60;
document.cookie = `${name}=${value};path=/;max-age=${maxAge};SameSite=Lax`;
};
const applyTheme = (appearance: Appearance) => {
if (appearance === 'contrast') {
document.documentElement.classList.remove('dark');
document.documentElement.classList.add('contrast');
} else if (appearance === 'dark') {
document.documentElement.classList.remove('contrast');
document.documentElement.classList.add('dark');
} else {
document.documentElement.classList.remove('contrast');
document.documentElement.classList.remove('dark');
}
};
const mediaQuery = () => {
if (typeof window === 'undefined') {
return null;
}
return window.matchMedia('(prefers-color-scheme: dark)');
};
export function initializeTheme() {
const savedAppearance = localStorage.getItem('appearance') as Appearance;
const prefersDark = mediaQuery()?.matches;
// If no saved appearance and user prefers dark, set dark mode
if (!savedAppearance && prefersDark) {
applyTheme('dark');
localStorage.setItem('appearance', 'dark');
} else {
applyTheme(savedAppearance || 'light');
}
}
export function useAppearance() {
const [appearance, setAppearance] = useState<Appearance>('light');
const updateAppearance = useCallback((mode: Appearance) => {
setAppearance(mode);
// Store in localStorage for client-side persistence...
localStorage.setItem('appearance', mode);
// Store in cookie for SSR...
setCookie('appearance', mode);
applyTheme(mode);
}, []);
useEffect(() => {
const savedAppearance = localStorage.getItem('appearance') as Appearance | null;
const prefersDark = mediaQuery()?.matches;
// If no saved appearance and user prefers dark, set dark mode
if (!savedAppearance && prefersDark) {
updateAppearance('dark');
} else {
updateAppearance(savedAppearance || 'light');
}
}, [updateAppearance]);
return { appearance, updateAppearance } as const;
}

View File

@@ -0,0 +1,27 @@
import { useEffect, useState } from 'react';
type FontSize = 'normal' | 'large' | 'larger';
export function useFontSize() {
const [fontSize, setFontSize] = useState<FontSize>(() => {
if (typeof window !== 'undefined') {
const saved = localStorage.getItem('font-size');
if (saved && ['normal', 'large', 'larger'].includes(saved)) {
return saved as FontSize;
}
}
return 'normal';
});
useEffect(() => {
const root = window.document.documentElement;
root.classList.remove('text-normal', 'text-large', 'text-larger');
root.classList.add(`text-${fontSize}`);
localStorage.setItem('font-size', fontSize);
}, [fontSize]);
return {
fontSize,
setFontSize,
};
}

View File

@@ -0,0 +1,8 @@
import { useCallback } from 'react';
export function useMobileNavigation() {
return useCallback(() => {
// Remove pointer-events style from body...
document.body.style.removeProperty('pointer-events');
}, []);
}

View File

@@ -0,0 +1,22 @@
import { useEffect, useState } from 'react';
const MOBILE_BREAKPOINT = 768;
export function useIsMobile() {
const [isMobile, setIsMobile] = useState<boolean>();
useEffect(() => {
const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`);
const onChange = () => {
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT);
};
mql.addEventListener('change', onChange);
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT);
return () => mql.removeEventListener('change', onChange);
}, []);
return !!isMobile;
}

View File

@@ -0,0 +1,21 @@
import { type BreadcrumbItem } from '@/types';
import { type ReactNode } from 'react';
import Header from '@/components/header/header';
import Nav from '@/components/nav/nav';
import Footer from '@/components/footer/footer';
import { Breadcrumbs } from '@/components/breadcrumbs';
interface AppLayoutProps {
children: ReactNode;
breadcrumbs?: BreadcrumbItem[];
}
export default ({ children, breadcrumbs, ...props }: AppLayoutProps) => (
<>
<Header {...props} />
<Nav />
{breadcrumbs && <Breadcrumbs breadcrumbs={breadcrumbs} />}
{children}
<Footer />
</>
);

View File

@@ -0,0 +1,6 @@
import { type ClassValue, clsx } from 'clsx';
import { twMerge } from 'tailwind-merge';
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}

View File

@@ -0,0 +1,237 @@
import { Head } from '@inertiajs/react';
import { useEffect, useState } from 'react';
import { type BreadcrumbItem } from '@/types';
import AppLayout from '@/layouts/app-layout';
import { Link } from '@inertiajs/react';
import { ImageLightbox, SingleImageLightbox } from '@/components/image-lightbox';
import AttachmentsList from '@/components/attachments-list';
const truncateText = (text: string, maxLength: number): string => {
if (text.length <= maxLength) return text;
return text.slice(0, maxLength) + '…';
};
const getBreadcrumbs = (section: string, slug: string, categorySlug: string | null, categoryTitle: string | null): BreadcrumbItem[] => {
const breadcrumbs: BreadcrumbItem[] = [
{
title: 'Strona Główna',
href: '/',
}
];
if (categorySlug && categoryTitle) {
breadcrumbs.push({
title: truncateText(categoryTitle, 35),
href: '#',
disabled: true
});
}
breadcrumbs.push({
title: truncateText(section, 35),
href: '#',
disabled: true
});
return breadcrumbs;
};
interface Photo {
id: number;
image_name: string;
image_desc: string;
image_path: string;
created_at: string;
updated_at: string;
}
interface Attachment {
id: number;
file_name: string;
file_path: string;
created_at: string;
updated_at: string;
}
interface Category {
id: number;
title: string;
slug: string;
}
interface Article {
id: number;
title: string;
slug: string;
thumbnail: string;
body: string;
additional_body?: string;
map_body?: string;
active: boolean;
type: string;
external: number;
published_at: string;
created_at: string;
updated_at: string;
user_id: number;
photos: Photo[];
attachments: Attachment[];
map_photo?: Photo;
categories: Category[];
}
interface ArticleApiResponse {
articles: Article[];
}
interface ArticlePageProps {
slug: string;
categorySlug: string | null;
}
export default function ArticlePage(props: ArticlePageProps) {
const { slug, categorySlug } = props;
const [article, setArticle] = useState<Article | null>(null);
const [categoryTitle, setCategoryTitle] = useState<string | null>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
setLoading(true);
fetch(`/api/articles?slug=${encodeURIComponent(slug)}`)
.then(response => response.json())
.then((data: ArticleApiResponse) => {
if (data.articles && data.articles.length > 0) {
const fetchedArticle = data.articles[0];
setArticle(fetchedArticle);
if (fetchedArticle.categories && fetchedArticle.categories.length > 0) {
const matchingCategory = categorySlug ?
fetchedArticle.categories.find(cat => cat.slug === categorySlug) : null;
if (matchingCategory) {
setCategoryTitle(matchingCategory.title);
} else if (fetchedArticle.categories.length > 0) {
setCategoryTitle(fetchedArticle.categories[0].title);
}
}
} else {
setArticle(null);
}
setLoading(false);
})
.catch(error => {
setArticle(null);
setLoading(false);
console.error('Error fetching article:', error);
});
}, [slug, categorySlug]);
const sectionName = article?.title || slug.replace(/-/g, ' ');
return (
<AppLayout breadcrumbs={getBreadcrumbs(sectionName, slug, categorySlug, categoryTitle)}>
<Head title={`${sectionName ? `${truncateText(sectionName, 35)}` : ''}`}/>
<div>
<main className={`bg-background ${article?.map_photo ? 'max-w-screen-xl' : 'max-w-screen-lg'} mx-auto px-4 xl:px-0 pt-8 pb-12 min-h-[75vh]`}>
{loading ? (
<div className="flex justify-center items-center h-64">
<div className="text-lg">Wczytywanie zawartości...</div>
</div>
) : !article ? (
<div className="flex justify-center items-center h-64">
<div className="text-lg">Nie znaleziono artykułu.</div>
</div>
) : (
<div>
{article.type === 'article-with-map' && article.map_photo ? (
<div className="flex flex-col md:flex-row gap-8">
<div className="md:w-1/2">
<h1 className="text-3xl font-bold mb-6">{article.title}</h1>
{article.published_at && (
<div className="mb-4 text-foreground">
Data publikacji: {new Date(article.published_at).toLocaleDateString('pl-PL')}
</div>
)}
<div className="text-foreground content" dangerouslySetInnerHTML={{ __html: article.body }}></div>
{article.additional_body && (
<div className="mt-6 p-4 bg-muted rounded-lg">
<div className="text-foreground content" dangerouslySetInnerHTML={{ __html: article.additional_body }}></div>
</div>
)}
</div>
<div className="md:w-1/2 md:sticky md:top-8">
<SingleImageLightbox
image={{
image_path: article.map_photo.image_path,
image_desc: article.map_photo.image_desc || 'Mapa lokalizacyjna'
}}
/>
<p className="mt-2 text-sm text-muted-foreground">
Kliknij zdjęcie, aby powiększyć
</p>
{article.map_body && (
<div className="mt-4 p-4">
<div className="text-foreground content" dangerouslySetInnerHTML={{ __html: article.map_body }}></div>
</div>
)}
</div>
</div>
) : (
<div>
<h1 className="text-3xl font-bold mb-6">{article.title}</h1>
{article.published_at && (
<div className="my-4 text-foreground">
Data publikacji: {new Date(article.published_at).toLocaleDateString('pl-PL')}
</div>
)}
<div className="flex flex-col-reverse md:flex-row gap-8 items-start">
<div className="text-foreground content w-full md:flex-1" dangerouslySetInnerHTML={{ __html: article.body }}></div>
{article.thumbnail && (
<div className="md:max-w-[300px] md:flex-shrink-0">
<SingleImageLightbox
image={{
image_path: article.thumbnail,
image_desc: article.title
}}
/>
</div>
)}
</div>
{article.additional_body && (
<div className="mt-6 p-4 bg-muted rounded-lg">
<div className="text-foreground content" dangerouslySetInnerHTML={{ __html: article.additional_body }}></div>
</div>
)}
</div>
)}
{article.photos && article.photos.length > 0 && (
<div className="mt-8">
<h2 className="text-2xl font-semibold mb-4">Galeria</h2>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{article.photos.map((photo, index) => (
<ImageLightbox
key={photo.id}
images={article.photos}
initialIndex={index}
/>
))}
</div>
</div>
)}
<AttachmentsList attachments={article.attachments || []} />
</div>
)}
</main>
</div>
</AppLayout>
);
}

View File

@@ -0,0 +1,397 @@
import { Head } from '@inertiajs/react';
import { useState, useEffect } from 'react';
import AppLayout from '@/layouts/app-layout';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Checkbox } from '@/components/ui/checkbox';
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from '@/components/ui/card';
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
import { type BreadcrumbItem } from '@/types';
import { Textarea } from '@/components/ui/textarea';
import { cn } from '@/lib/utils';
const breadcrumbs: BreadcrumbItem[] = [
{
title: 'Strona główna',
href: '/',
},
{
title: 'Kontakt',
href: '/kontakt',
},
];
interface ContactData {
system_email: string;
telephone: string;
email: string;
address: string;
fax: string;
}
interface ContactApiResponse {
contact: ContactData;
}
export default function Contact() {
const [contactData, setContactData] = useState<ContactData | null>(null);
const [loading, setLoading] = useState(true);
const [formData, setFormData] = useState({
name: '',
email: '',
phone: '',
subject: '',
message: '',
agreement: false,
});
const [errors, setErrors] = useState<Record<string, string>>({});
const [submitStatus, setSubmitStatus] = useState<{
success: boolean;
message: string;
} | null>(null);
const [submitting, setSubmitting] = useState(false);
useEffect(() => {
fetch('/api/contact')
.then(response => response.json())
.then((data: ContactApiResponse) => {
setContactData(data.contact);
setLoading(false);
})
.catch(error => {
console.error('Error fetching contact data:', error);
setLoading(false);
});
}, []);
const handleChange = (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => {
const { name, value } = e.target;
setFormData(prev => ({
...prev,
[name]: value,
}));
// Clear error for this field when user starts typing
if (errors[name]) {
setErrors(prev => {
const newErrors = { ...prev };
delete newErrors[name];
return newErrors;
});
}
};
const handleCheckboxChange = (checked: boolean) => {
setFormData(prev => ({
...prev,
agreement: checked,
}));
// Clear error for agreement when user checks it
if (errors.agreement) {
setErrors(prev => {
const newErrors = { ...prev };
delete newErrors.agreement;
return newErrors;
});
}
};
const validateForm = () => {
const newErrors: Record<string, string> = {};
if (!formData.name.trim()) {
newErrors.name = 'Imię i nazwisko jest wymagane';
}
if (!formData.email.trim()) {
newErrors.email = 'Adres e-mail jest wymagany';
} else if (!/^\S+@\S+\.\S+$/.test(formData.email)) {
newErrors.email = 'Podaj poprawny adres e-mail';
}
if (!formData.subject.trim()) {
newErrors.subject = 'Temat jest wymagany';
}
if (!formData.message.trim()) {
newErrors.message = 'Treść wiadomości jest wymagana';
}
if (!formData.agreement) {
newErrors.agreement = 'Wymagana jest zgoda na przetwarzanie danych osobowych';
}
setErrors(newErrors);
return Object.keys(newErrors).length === 0;
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!validateForm()) {
return;
}
setSubmitting(true);
setSubmitStatus(null);
try {
const csrfToken = document.head?.querySelector('meta[name="csrf-token"]')?.getAttribute('content') || '';
const response = await fetch('/kontakt/wyslij', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-TOKEN': csrfToken,
'Accept': 'application/json',
'X-Requested-With': 'XMLHttpRequest'
},
body: JSON.stringify(formData)
});
const data = await response.json();
if (response.ok) {
setSubmitStatus({
success: true,
message: data.message || 'Wiadomość została wysłana pomyślnie.'
});
// Resetowanie formularza po udanym wysłaniu
setFormData({
name: '',
email: '',
phone: '',
subject: '',
message: '',
agreement: false,
});
} else {
setSubmitStatus({
success: false,
message: data.message || 'Wystąpił błąd podczas wysyłania wiadomości.'
});
if (data.errors) {
setErrors(data.errors);
}
}
} catch (error) {
console.error('Błąd wysyłania formularza:', error);
setSubmitStatus({
success: false,
message: 'Wystąpił błąd podczas wysyłania wiadomości. Spróbuj ponownie później.'
});
} finally {
setSubmitting(false);
}
};
return (
<AppLayout breadcrumbs={breadcrumbs}>
<Head title="Kontakt" />
<div className="bg-background">
<main className="max-w-screen-xl mx-auto px-4 xl:px-0 py-8">
<h1 className="text-3xl font-bold mb-6">Kontakt</h1>
<div className="grid grid-cols-1 md:grid-cols-2 gap-8">
<div>
<Card>
<CardHeader>
<CardTitle>Dane kontaktowe</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
{loading ? (
<div className="animate-pulse space-y-3">
<div className="h-4 bg-muted rounded w-3/4"></div>
<div className="h-4 bg-muted rounded w-1/2"></div>
<div className="h-4 bg-muted rounded w-2/3"></div>
<div className="h-4 bg-muted rounded w-3/5"></div>
<div className="h-4 bg-muted rounded w-1/3"></div>
</div>
) : contactData ? (
<>
<div>
<h3 className="font-medium text-muted-foreground">Adres:</h3>
<p className="mt-1" dangerouslySetInnerHTML={{ __html: contactData.address }}></p>
</div>
<div>
<h3 className="font-medium text-muted-foreground">Telefon:</h3>
<p className="mt-1">{contactData.telephone}</p>
</div>
<div>
<h3 className="font-medium text-muted-foreground">Fax:</h3>
<p className="mt-1">{contactData.fax}</p>
</div>
<div>
<h3 className="font-medium text-muted-foreground">E-mail:</h3>
<p className="mt-1">
<a
href={`mailto:${contactData.email}`}
className="text-primary hover:underline"
>
{contactData.email}
</a>
</p>
</div>
</>
) : (
<p>Nie udało się załadować danych kontaktowych.</p>
)}
</CardContent>
</Card>
<div className="mt-8">
<Card>
<CardHeader>
<CardTitle>Mapa</CardTitle>
</CardHeader>
<CardContent>
<div className="aspect-video rounded-md overflow-hidden">
<iframe
src="https://www.google.com/maps/embed?pb=!1m18!1m12!1m3!1d2420.3711291827294!2d16.8160558!3d52.6532738!2m3!1f0!2f0!3f0!3m2!1i1024!2i768!4f13.1!3m3!1m2!1s0x47046cc587759f69%3A0xe9da2228ba6bf067!2sSamodzielny%20Publiczny%20Zak%C5%82ad%20Opieki%20Zdrowotnej%20w%20Obornikach!5e0!3m2!1spl!2spl!4v1745879921955!5m2!1spl!2spl"
width="100%"
height="100%"
style={{ border: 0 }}
allowFullScreen
loading="lazy"
referrerPolicy="no-referrer-when-downgrade"
></iframe>
</div>
</CardContent>
</Card>
</div>
</div>
<div>
<Card>
<CardHeader>
<CardTitle>Formularz kontaktowy</CardTitle>
<CardDescription>Skontaktuj się z nami</CardDescription>
</CardHeader>
<CardContent>
{submitStatus && (
<Alert className={`mb-6 ${submitStatus.success ? 'bg-green-50 text-green-800 border-green-200 contrast:bg-black contrast:border-green-700' : 'bg-red-50 dark:bg-red-200 contrast:bg-black contrast:border-red-700 text-red-800 border-red-200'}`}>
<AlertTitle className='contrast:text-foreground'>{submitStatus.success ? 'Sukces!' : 'Błąd!'}</AlertTitle>
<AlertDescription className="dark:text-black">{submitStatus.message}</AlertDescription>
</Alert>
)}
<form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="name">Imię i nazwisko *</Label>
<Input
id="name"
name="name"
autoComplete='off'
value={formData.name}
onChange={handleChange}
className={errors.name ? 'border-red-500' : ''}
/>
{errors.name && (
<p className="text-sm text-red-500">{errors.name}</p>
)}
</div>
<div className="space-y-2">
<Label htmlFor="email">Adres e-mail *</Label>
<Input
id="email"
name="email"
autoComplete='off'
type="email"
value={formData.email}
onChange={handleChange}
className={errors.email ? 'border-red-500' : ''}
/>
{errors.email && (
<p className="text-sm text-red-500">{errors.email}</p>
)}
</div>
<div className="space-y-2">
<Label htmlFor="phone">Telefon</Label>
<Input
autoComplete='off'
id="phone"
name="phone"
value={formData.phone}
onChange={handleChange}
/>
</div>
<div className="space-y-2">
<Label htmlFor="subject">Temat *</Label>
<Input
autoComplete='off'
id="subject"
name="subject"
value={formData.subject}
onChange={handleChange}
className={errors.subject ? 'border-red-500' : ''}
/>
{errors.subject && (
<p className="text-sm text-red-500">{errors.subject}</p>
)}
</div>
<div className="space-y-2">
<Label htmlFor="message">Treść wiadomości *</Label>
<Textarea
autoComplete='off'
id="message"
name="message"
rows={5}
value={formData.message}
onChange={handleChange}
className={cn('dark:bg-transparent', errors.message ? 'border-red-500' : '')}
/>
{errors.message && (
<p className="text-sm text-red-500">{errors.message}</p>
)}
</div>
<div className="flex items-start space-x-2">
<Checkbox
id="agreement"
checked={formData.agreement}
onCheckedChange={handleCheckboxChange}
className={`mt-1 ${errors.agreement ? 'border-red-500' : ''}`}
/>
<div className="grid gap-1.5 leading-none">
<Label
htmlFor="agreement"
className="text-sm font-normal"
>
Wyrażam zgodę na przetwarzanie moich danych osobowych podanych w powyższym formularzu. *
</Label>
{errors.agreement && (
<p className="text-sm text-red-500">{errors.agreement}</p>
)}
</div>
</div>
<div className="mt-6">
<Button
type="submit"
disabled={submitting}
className="cursor-pointer"
>
{submitting ? 'Wysyłanie...' : 'Wyślij wiadomość'}
</Button>
</div>
</form>
</CardContent>
</Card>
</div>
</div>
</main>
</div>
</AppLayout>
);
}

View File

@@ -0,0 +1,282 @@
import { Head } from '@inertiajs/react';
import { useEffect, useState } from 'react';
import AppLayout from '@/layouts/app-layout';
import { Pagination } from '@/components/pagination';
import { Card, CardContent, CardFooter, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Calendar, FileText, Download } from 'lucide-react';
import { Skeleton } from '@/components/ui/skeleton';
import { SingleImageLightbox } from '@/components/image-lightbox';
const globalStyles = `
.diet-photo-container img {
height: 100% !important;
object-fit: cover !important;
width: 100% !important;
}
`;
import { type BreadcrumbItem } from '@/types';
interface Diet {
id: number;
name: string;
breakfast_photo: string | null;
lunch_photo: string | null;
breakfast_body: string | null;
lunch_body: string | null;
diet_attachment: string | null;
published_at: string;
}
interface PaginationData {
current_page: number;
last_page: number;
per_page: number;
total: number;
}
interface DietsData {
diets: Diet[];
pagination: PaginationData;
}
interface DietsProps {
page?: string | number;
}
const getBreadcrumbs = (): BreadcrumbItem[] => [
{
title: 'Strona Główna',
href: '/',
},
{
title: 'Pilotaż "Dobry posiłek"',
href: '/diety',
},
];
export default function Diets({ page = 1 }: DietsProps) {
const [dietsData, setDietsData] = useState<DietsData | null>(null);
const [loading, setLoading] = useState(true);
const [currentPage, setCurrentPage] = useState(typeof page === 'string' ? parseInt(page) : page);
const fetchDiets = (page: number) => {
setLoading(true);
fetch(`/api/diets?page=${page}&per_page=7`)
.then(response => response.json())
.then(data => {
setDietsData(data);
setLoading(false);
})
.catch(error => {
console.error('Błąd podczas pobierania diet:', error);
setLoading(false);
});
};
useEffect(() => {
fetchDiets(currentPage);
}, [currentPage]);
useEffect(() => {
const initialPage = typeof page === 'string' ? parseInt(page) : page;
if (initialPage !== currentPage) {
setCurrentPage(initialPage);
}
}, [page]);
const handlePageChange = (page: number) => {
setCurrentPage(page);
window.scrollTo(0, 0);
if (page === 1) {
window.history.pushState({}, '', '/diety');
} else {
window.history.pushState({}, '', `/diety?strona=${page}`);
}
};
const formatDate = (dateString: string) => {
const date = new Date(dateString);
return date.toLocaleDateString('pl-PL', { day: 'numeric', month: 'long', year: 'numeric' });
};
return (
<AppLayout breadcrumbs={getBreadcrumbs()}>
<style dangerouslySetInnerHTML={{ __html: globalStyles }} />
<Head title='Pilotaż "Dobry posiłek"' />
<div className="container mx-auto px-4 py-8 max-w-4xl">
<h1 className="text-3xl font-bold mb-6">Pilotaż "Dobry posiłek"</h1>
<div className="mb-8 p-6 rounded-lg">
<p className="mb-4">
Samodzielny Publiczny Zakład Opieki Zdrowotnej w Obornikach dołączył do programu pilotażowego Dobry posiłek w szpitalu", który ma celu poprawę żywienia w szpitalach poprzez zwiększenie dostępności porad żywieniowych oraz wdrożenie optymalnego modelu żywienia Pacjentów. <a href="https://isap.sejm.gov.pl/isap.nsf/DocDetails.xsp?id=WDU20230002021" target="_blank" className="text-primary hover:underline">Kliknij tutaj aby przejść do Rozporządzenia Ministra Zdrowia z dnia 25 września 2023 r. w sprawie programu pilotażowego w zakresie edukacji żywieniowej oraz poprawy jakości żywienia w szpitalach „Dobry posiłek w szpitalu"</a>
</p>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6 mb-4">
<div className='content'>
<h3 className="font-semibold mb-2">Godziny wydawania posiłków:</h3>
<ul className="list-disc pl-5 space-y-1">
<li>Śniadanie 8:00 - 9:00</li>
<li>Obiad 13:00 - 14:00</li>
<li>Kolacja 18:00 - 19:00</li>
<li>II Kolacja / posiłek nocny 19:00 - 20:00</li>
<li>Podwieczorek 14:30 - 15:00</li>
</ul>
</div>
<div className='content'>
<h3 className="font-semibold mb-2">Najczęściej stosowane diety:</h3>
<ul className="list-disc pl-5 space-y-1">
<li>dieta podstawowa</li>
<li>dieta łatwostrawna</li>
<li>dieta z ograniczeniem łatwo przyswajalnych węglowodanów (cukrzycowa)</li>
<li>dieta papkowata</li>
<li>dieta bogatobiałkowa</li>
</ul>
</div>
</div>
<p className="mt-2 text-sm">
Stosujemy również inne diety, lub ich modyfikacje w zależności od zaleceń lekarza.
W ramach programu, na zlecenie lekarza odbywają się również konsultacje dietetyczne.
</p>
</div>
{loading ? (
<div className="space-y-6">
{[1, 2, 3].map((i) => (
<Card key={i} className="overflow-hidden">
<CardHeader>
<Skeleton className="h-8 w-3/4" />
</CardHeader>
<CardContent>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div>
<Skeleton className="h-64 w-full" />
<Skeleton className="h-40 w-full mt-4" />
</div>
<div>
<Skeleton className="h-64 w-full" />
<Skeleton className="h-40 w-full mt-4" />
</div>
</div>
</CardContent>
<CardFooter>
<Skeleton className="h-10 w-48" />
</CardFooter>
</Card>
))}
</div>
) : (
<>
{dietsData?.diets && dietsData.diets.length > 0 ? (
<div className="space-y-8">
{dietsData.diets.map((diet: Diet) => (
<Card key={diet.id} className="overflow-hidden">
<CardHeader>
<CardTitle className="text-2xl">{diet.name}</CardTitle>
<div className="flex items-center text-sm text-gray-500">
<Calendar className="w-4 h-4 mr-1 dark:text-primary contrast:text-foreground" />
<span className="dark:text-primary contrast:text-foreground">{formatDate(diet.published_at)}</span>
</div>
</CardHeader>
<CardContent>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div>
<h3 className="text-xl font-semibold mb-3">Śniadanie</h3>
{diet.breakfast_photo && (
<div className="mb-4">
<div className="h-64 overflow-hidden rounded-lg shadow-md diet-photo-container">
{diet.breakfast_photo && (
<div className="h-full">
<SingleImageLightbox
image={{
image_path: diet.breakfast_photo,
image_desc: "Śniadanie: " + diet.name
}}
/>
</div>
)}
</div>
</div>
)}
{diet.breakfast_body && (
<div
className="text-sm text-gray-700 mt-3 content text-black dark:text-white contrast:text-foreground"
dangerouslySetInnerHTML={{ __html: diet.breakfast_body }}
/>
)}
</div>
<div>
<h3 className="text-xl font-semibold mb-3">Obiad</h3>
{diet.lunch_photo && (
<div className="mb-4">
<div className="h-64 overflow-hidden rounded-lg shadow-md diet-photo-container">
{diet.lunch_photo && (
<div className="h-full">
<SingleImageLightbox
image={{
image_path: diet.lunch_photo,
image_desc: "Obiad: " + diet.name
}}
/>
</div>
)}
</div>
</div>
)}
{diet.lunch_body && (
<div
className="text-sm text-gray-700 mt-3 content text-black dark:text-white contrast:text-foreground"
dangerouslySetInnerHTML={{ __html: diet.lunch_body }}
/>
)}
</div>
</div>
</CardContent>
<CardFooter>
{diet.diet_attachment && (
<a
href={diet.diet_attachment}
target="_blank"
rel="noopener noreferrer"
className="inline-flex"
>
<Button variant="outline" className="flex items-center">
<FileText className="w-4 h-4 mr-2" />
<span className="mr-2">Pobierz jadłospis</span>
<Download className="w-4 h-4" />
</Button>
</a>
)}
</CardFooter>
</Card>
))}
{dietsData.pagination && dietsData.pagination.last_page > 1 && (
<Pagination
currentPage={dietsData.pagination.current_page}
lastPage={dietsData.pagination.last_page}
onPageChange={handlePageChange}
className="my-8"
/>
)}
</div>
) : (
<div className="bg-gray-50 rounded-lg p-8 text-center">
<p className="text-gray-600">Brak dostępnych diet do wyświetlenia.</p>
</div>
)}
</>
)}
</div>
</AppLayout>
);
}

View File

@@ -0,0 +1,136 @@
import { Head } from '@inertiajs/react';
import { useEffect, useState } from 'react';
import { type BreadcrumbItem } from '@/types';
import AppLayout from '@/layouts/app-layout';
import { Phone, Mail } from 'lucide-react';
import { Link } from '@inertiajs/react';
const getBreadcrumbs = (section: string, slug: string): BreadcrumbItem[] => [
{
title: 'Strona Główna',
href: '/',
},
{
title: section || slug.replace(/-/g, ' '),
href: `/administracja/${slug}`,
},
];
interface Employee {
name: string;
slug?: string;
sort_order?: number;
employees: Array<{
type: string;
data: {
[key: string]: string | null;
};
}>;
}
interface EmployeesApiResponse {
main: Record<string, Employee>;
extra: Record<string, Employee>;
}
interface EmployeesPageProps {
slug: string;
}
export default function Employees(props: EmployeesPageProps) {
const { slug } = props;
const [employees, setEmployees] = useState<EmployeesApiResponse | null>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
setLoading(true);
fetch(`/api/employees?slug=${encodeURIComponent(slug)}`)
.then(response => response.json())
.then(data => {
setEmployees(data);
setLoading(false);
})
.catch(error => {
setEmployees(null);
setLoading(false);
console.error('Error fetching employees:', error);
});
}, [slug]);
const mainSections = employees?.main ? Object.values(employees.main) : [];
const extraSections = employees?.extra ? Object.values(employees.extra) : [];
const sectionName = mainSections.length > 0 ? mainSections[0].name : '';
return (
<AppLayout breadcrumbs={getBreadcrumbs(sectionName, '')}>
<Head title={`${sectionName ? `${sectionName}` : ''}`}/>
<div>
<main className="bg-background max-w-screen-lg mx-auto px-4 xl:px-0 pt-8 pb-12 min-h-[75vh]">
<h1 className="text-3xl font-bold mb-6">{sectionName}</h1>
{loading ? (
<div>Pobieranie listy pracowników...</div>
) : !employees || (mainSections.length === 0 && extraSections.length === 0) ? (
<>
<div>Sekcja nie istnieje lub brak pracowników w tej sekcji.</div>
<Link className='text-primary font-bold hover:underline' href={route("home")}>Powrót na stronę główną</Link>
</>
) : (
<>
{mainSections
.sort((a: any, b: any) => (a.sort_order ?? 0) - (b.sort_order ?? 0))
.map((section: any, index: number) => (
<div key={index} className="mb-10">
<ul className="flex flex-col gap-4">
{section.employees.map((person: any, idx: number) => (
<li key={idx} className="bg-page-card dark:bg-page-card shadow-md rounded-lg p-4 flex items-center gap-4 w-full contrast:border contrast:border-foreground/60">
<div className="flex-shrink-0 w-12 h-12 rounded-full bg-primary text-white dark:text-black contrast:text-black flex items-center justify-center text-xl font-bold uppercase">
{person.data.first_name?.[0] || ''}{person.data.last_name?.[0] || ''}
</div>
<div className="flex flex-col flex-1 min-w-0">
<div className="font-semibold text-lg text-black dark:text-gray-300 contrast:text-foreground truncate">{person.data.title ? person.data.title + ' ' : ''}{person.data.first_name} {person.data.last_name}</div>
{person.data.position && <div className="text-sm text-foreground truncate">{person.data.position}</div>}
<div className="flex flex-wrap gap-4 mt-1">
{person.data.email && <div className="flex items-center gap-2 text-sm text-foreground font-semibold contrast:text-foreground"><Mail className="h-4 w-4" /> {person.data.email}</div>}
{person.data.phone && <div className="flex items-center gap-2 text-sm text-foreground font-semibold contrast:text-foreground"><Phone className="h-4 w-4" /> {person.data.phone}</div>}
</div>
</div>
</li>
))}
</ul>
</div>
))}
{extraSections.length > 0 && (
<div className="mt-16">
{extraSections
.sort((a: any, b: any) => (a.sort_order ?? 0) - (b.sort_order ?? 0))
.map((section: any) => (
<div key={section.name} className="mb-10">
<h2 className="text-xl font-semibold mb-4">{section.name}</h2>
<ul className="flex flex-col gap-4">
{section.employees.map((person: any, idx: number) => (
<li key={idx} className="bg-page-card dark:bg-page-card shadow-md rounded-lg p-4 flex items-center gap-4 w-full contrast:border contrast:border-foreground/60">
<div className="flex-shrink-0 w-12 h-12 rounded-full bg-primary text-white dark:text-black contrast:text-black flex items-center justify-center text-xl font-bold uppercase">
{person.data.first_name?.[0] || ''}{person.data.last_name?.[0] || ''}
</div>
<div className="flex flex-col flex-1 min-w-0">
<div className="font-semibold text-lg text-black dark:text-gray-300 contrast:text-foreground truncate">{person.data.title ? person.data.title + ' ' : ''}{person.data.first_name} {person.data.last_name}</div>
{person.data.position && <div className="text-sm text-foreground truncate">{person.data.position}</div>}
<div className="flex flex-wrap gap-4 mt-1">
{person.data.email && <div className="flex items-center gap-2 text-sm text-foreground font-semibold contrast:text-foreground"><Mail className="h-4 w-4" /> {person.data.email}</div>}
{person.data.phone && <div className="flex items-center gap-2 text-sm text-foreground font-semibold contrast:text-foreground"><Phone className="h-4 w-4" /> {person.data.phone}</div>}
</div>
</div>
</li>
))}
</ul>
</div>
))}
</div>
)}
</>
)}
</main>
</div>
</AppLayout>
);
}

View File

@@ -0,0 +1,64 @@
import { Head } from '@inertiajs/react';
import { useEffect, useState } from 'react';
import AppLayout from '@/layouts/app-layout';
import HomeCarousel from '@/components/home-carousel/home-carousel';
interface HomepageData {
title?: string;
content?: string;
photo?: string;
}
export default function Home() {
const [homepageData, setHomepageData] = useState<HomepageData | null>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
setLoading(true);
fetch('/api/homepage')
.then(response => response.json())
.then(data => {
setHomepageData(data);
setLoading(false);
})
.catch(error => {
console.error('Error fetching homepage data:', error);
setLoading(false);
});
}, []);
return (
<AppLayout>
<Head title="Strona główna" />
<HomeCarousel />
<div>
<main className="bg-background max-w-screen-xl mx-auto px-4 xl:px-0 pb-12">
{loading ? (
<div className="flex justify-center items-center h-64">
<div className="text-lg">Wczytywanie...</div>
</div>
) : (
<article>
<div className="flex flex-col md:flex-row gap-8">
<div className="md:w-1/2">
<h1 className="text-3xl font-bold text-black dark:text-white contrast:text-foreground mb-6">{homepageData?.title}</h1>
<div className="text-lg content" dangerouslySetInnerHTML={{ __html: homepageData?.content || '' }}></div>
</div>
{homepageData?.photo && (
<div className="md:w-1/2 md:sticky md:top-8">
<img
className="w-full rounded-lg shadow-md"
src={homepageData.photo}
alt="Zdjęcie przedstawiające szpital"
/>
</div>
)}
</div>
</article>
)}
</main>
</div>
</AppLayout>
);
}

View File

@@ -0,0 +1,114 @@
import { Head, Link } from '@inertiajs/react';
import { useEffect, useState } from 'react';
import { type BreadcrumbItem } from '@/types';
import AppLayout from '@/layouts/app-layout';
import AttachmentsList from '@/components/attachments-list';
const breadcrumbs: BreadcrumbItem[] = [
{
title: 'Strona Główna',
href: '/',
},
{
title: 'Oferty pracy',
href: '/oferty-pracy',
},
];
interface Attachment {
id: number;
file_name: string;
file_path: string;
file_size: number;
file_type: string;
created_at: string;
updated_at: string;
}
interface Photo {
id: number;
image_name: string;
image_path: string;
image_size: number;
image_type: string;
created_at: string;
updated_at: string;
}
interface JobOffer {
id: number;
title: string;
slug: string;
thumbnail: string;
body: string;
active: boolean;
published_at: string;
created_at: string;
updated_at: string;
user_id: number;
attachments?: Attachment[];
photos?: Photo[];
}
interface JobOffersApiResponse {
jobOffers: JobOffer[];
}
export default function JobOffers() {
const [jobOffers, setJobOffers] = useState<JobOffersApiResponse | null>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
setLoading(true);
fetch(`/api/joboffers`)
.then(response => response.json())
.then(data => {
setJobOffers(data);
setLoading(false);
})
.catch(error => {
setJobOffers(null);
setLoading(false);
console.error('Error fetching job offers data:', error);
});
}, []);
return (
<AppLayout breadcrumbs={breadcrumbs}>
<Head title="Oferty pracy"/>
<div>
<main className="bg-background max-w-screen-lg mx-auto px-4 xl:px-0 pt-8 pb-12">
<h1 className="text-3xl font-bold mb-6">Oferty pracy</h1>
{loading ? (
<div className="flex justify-center items-center h-64">
<div className="text-lg">Wczytywanie...</div>
</div>
) : !jobOffers || jobOffers.jobOffers.length === 0 ? (
<div className="flex justify-center items-center h-64">
<div className="text-lg">Brak ofert pracy.</div>
</div>
) : (
<div>
<div className="space-y-8 mb-8">
{jobOffers.jobOffers.map((jobOffer) => {
return (
<div key={jobOffer.id} className="p-6 bg-card rounded-lg shadow-sm">
<h2 className="text-2xl font-bold mb-2">{jobOffer.title}</h2>
<div className="mb-2 text-muted-foreground">Data publikacji: {new Date(jobOffer.published_at).toLocaleDateString('pl-PL')}</div>
<div className="text-foreground mb-4 content" dangerouslySetInnerHTML={{ __html: jobOffer.body }}></div>
<AttachmentsList
attachments={jobOffer.attachments || []}
title="Załączniki"
/>
</div>
);
})}
</div>
</div>
)}
</main>
</div>
</AppLayout>
);
}

View File

@@ -0,0 +1,163 @@
import { Head } from '@inertiajs/react';
import { useEffect, useState } from 'react';
import { type BreadcrumbItem } from '@/types';
import AppLayout from '@/layouts/app-layout';
import { Phone, Mail } from 'lucide-react';
import { Link } from '@inertiajs/react';
import { SingleImageLightbox, ImageLightbox } from '@/components/image-lightbox';
import AttachmentsList from '@/components/attachments-list';
const truncateText = (text: string, maxLength: number): string => {
if (text.length <= maxLength) return text;
return text.slice(0, maxLength) + '…';
};
const getBreadcrumbs = (section: string, slug: string): BreadcrumbItem[] => [
{
title: 'Strona Główna',
href: '/',
},
{
title: 'Aktualności',
href: '/aktualnosci',
},
{
title: truncateText(section, 35),
href: `/aktualnosci/${slug}`,
},
];
interface Photo {
id: number;
image_name: string;
image_desc: string;
image_path: string;
created_at: string;
updated_at: string;
}
interface Attachment {
id: number;
file_name: string;
file_path: string;
created_at: string;
updated_at: string;
}
interface News {
id: number;
title: string;
slug: string;
thumbnail: string;
body: string;
active: boolean;
published_at: string;
created_at: string;
updated_at: string;
user_id: number;
photos?: Photo[];
attachments?: Attachment[];
}
interface NewsApiResponse {
articleNews: Record<string, News>;
}
interface NewsPageProps {
slug: string;
}
export default function NewsSlug(props: NewsPageProps) {
const { slug } = props;
const [news, setNews] = useState<NewsApiResponse | null>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
setLoading(true);
fetch(`/api/news?slug=${encodeURIComponent(slug)}`)
.then(response => response.json())
.then(data => {
setNews(data);
setLoading(false);
})
.catch(error => {
setNews(null);
setLoading(false);
console.error('Error fetching news:', error);
});
}, [slug]);
const sectionName = news && Object.values(news.articleNews)[0]?.title || slug.replace(/-/g, ' ');
return (
<AppLayout breadcrumbs={getBreadcrumbs(sectionName, slug)}>
<Head title={`${sectionName ? `${truncateText(sectionName, 35)}` : ''}`}/>
<div>
<main className="bg-background max-w-screen-lg mx-auto px-4 xl:px-0 pt-8 pb-12 min-h-[75vh]">
{loading ? (
<div className="flex justify-center items-center h-64">
<div className="text-lg">Wczytywanie zawartości...</div>
</div>
) : !news || Object.keys(news.articleNews).length === 0 ? (
<div className="flex justify-center items-center h-64">
<div className="text-lg">Brak artykułów.</div>
</div>
) : (
<div>
{Object.values(news.articleNews).map((article) => (
<div key={article.id} className="mb-8">
{article.thumbnail ? (
<div className="flex flex-col md:flex-row gap-8">
<div className="md:w-1/2">
<h1 className="text-3xl font-bold mb-4">{article.title}</h1>
<div className="mb-4 text-foreground">Data publikacji: {new Date(article.published_at).toLocaleDateString('pl-PL')}</div>
<div className="text-foreground content" dangerouslySetInnerHTML={{ __html: article.body }}></div>
</div>
<div className="md:w-1/2 md:sticky md:top-8">
<SingleImageLightbox image={{ image_path: article.thumbnail, image_desc: article.title }} />
</div>
</div>
) : (
<div className="max-w-3xl mx-auto">
<h1 className="text-3xl font-bold mb-4">{article.title}</h1>
<div className="mb-4 text-foreground">Data publikacji: {new Date(article.published_at).toLocaleDateString('pl-PL')}</div>
<div className="text-foreground content" dangerouslySetInnerHTML={{ __html: article.body }}></div>
</div>
)}
{article.photos && article.photos.length > 0 && (
<div className={`mt-8 ${!article.thumbnail ? 'max-w-4xl mx-auto' : ''}`}>
<h2 className={`text-2xl font-semibold mb-4 ${!article.thumbnail ? 'text-center' : ''}`}>Galeria</h2>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{article.photos.map((photo, index) => (
<ImageLightbox
key={photo.id}
images={article.photos as Array<{
id: number;
image_path: string;
image_desc?: string;
}>}
initialIndex={index}
/>
))}
</div>
</div>
)}
<div className="max-w-3xl mx-auto mt-8">
<AttachmentsList attachments={article.attachments || []} />
</div>
</div>
))}
</div>
)}
</main>
</div>
</AppLayout>
);
}

View File

@@ -0,0 +1,149 @@
import { Head, Link } from '@inertiajs/react';
import { useEffect, useState } from 'react';
import { type BreadcrumbItem } from '@/types';
import AppLayout from '@/layouts/app-layout';
import { Pagination } from '@/components/pagination';
const breadcrumbs: BreadcrumbItem[] = [
{
title: 'Strona Główna',
href: '/',
},
{
title: 'Aktualności',
href: '/aktualnosci',
},
];
interface News {
id: number;
title: string;
slug: string;
thumbnail: string;
body: string;
active: boolean;
published_at: string;
created_at: string;
updated_at: string;
user_id: number;
}
interface PaginationData {
total: number;
per_page: number;
current_page: number;
last_page: number;
}
interface NewsApiResponse {
articleNews: Record<string, News>;
pagination: PaginationData;
}
interface NewsProps {
page?: string | number;
}
export default function News({ page = 1 }: NewsProps) {
const [news, setNews] = useState<NewsApiResponse | null>(null);
const [loading, setLoading] = useState(true);
const [currentPage, setCurrentPage] = useState(typeof page === 'string' ? parseInt(page) : page);
useEffect(() => {
const initialPage = typeof page === 'string' ? parseInt(page) : page;
if (initialPage !== currentPage) {
setCurrentPage(initialPage);
}
}, [page]);
useEffect(() => {
setLoading(true);
fetch(`/api/news?page=${currentPage}&per_page=10`)
.then(response => response.json())
.then(data => {
setNews(data);
setLoading(false);
})
.catch(error => {
setNews(null);
setLoading(false);
console.error('Error fetching news data:', error);
});
}, [currentPage]);
return (
<AppLayout breadcrumbs={breadcrumbs}>
<Head title="Aktualności"/>
<div>
<main className="bg-background max-w-screen-xl mx-auto px-4 xl:px-0 pt-8 pb-12">
<h1 className="text-3xl font-bold mb-6">Aktualności</h1>
{loading ? (
<div className="flex justify-center items-center h-64">
<div className="text-lg">Wczytywanie...</div>
</div>
) : !news || Object.keys(news.articleNews).length === 0 ? (
<div className="flex justify-center items-center h-64">
<div className="text-lg">Brak artykułów.</div>
</div>
) : (
<div>
<div className="space-y-8 mb-8">
{Object.values(news.articleNews).map((article) => {
function stripHtml(html: string) {
const htmlWithSpaces = html
.replace(/<br\s*\/?>/gi, ' ')
.replace(/<\/p><p>/gi, ' ')
.replace(/<\/div><div>/gi, ' ')
.replace(/<\/h[1-6]><h[1-6]>/gi, ' ')
.replace(/<\/li><li>/gi, ' ');
const div = document.createElement('div');
div.innerHTML = htmlWithSpaces;
return (div.textContent || div.innerText || '')
.replace(/\s+/g, ' ')
.trim();
}
const plainText = stripHtml(article.body);
const shortText = plainText.length > 200
? plainText.slice(0, 200).replace(/\s+$/, '') + '…'
: plainText;
return (
<div key={article.id} className="p-6 bg-card rounded-lg shadow-sm">
<h2 className="text-2xl font-bold mb-2">{article.title}</h2>
<div className="mb-2 text-muted-foreground">Data publikacji: {new Date(article.published_at).toLocaleDateString('pl-PL')}</div>
<div className="text-foreground mb-4">{shortText}</div>
<a
href={`/aktualnosci/${article.slug}`}
className="inline-block text-primary dark:text-accent hover:underline font-semibold contrast:font-bold"
>
Czytaj więcej
</a>
</div>
);
})}
</div>
{news.pagination && news.pagination.total > 10 && (
<Pagination
currentPage={currentPage}
lastPage={news.pagination.last_page}
onPageChange={(page) => {
setCurrentPage(page);
window.scrollTo(0, 0);
if (page === 1) {
window.history.pushState({}, '', '/aktualnosci');
} else {
window.history.pushState({}, '', `/aktualnosci?strona=${page}`);
}
}}
/>
)}
</div>
)}
</main>
</div>
</AppLayout>
);
}

View File

@@ -0,0 +1,132 @@
import { Head } from '@inertiajs/react';
import { useEffect, useState } from 'react';
import { type BreadcrumbItem } from '@/types';
import AppLayout from '@/layouts/app-layout';
import { Button } from '@/components/ui/button';
import { Download } from 'lucide-react';
const getBreadcrumbs = (categoryTitle: string | null): BreadcrumbItem[] => {
const breadcrumbs: BreadcrumbItem[] = [
{
title: 'Strona Główna',
href: '/',
}
];
if (categoryTitle) {
breadcrumbs.push({
title: categoryTitle,
href: '#',
disabled: true
});
}
breadcrumbs.push({
title: 'Cennik badań diagnostycznych',
href: '/cennik-badan',
});
return breadcrumbs;
};
interface PriceData {
id: number;
attachment_id: number;
created_at: string;
updated_at: string;
}
interface AttachmentData {
id: number;
file_name: string;
file_path: string;
created_at: string;
updated_at: string;
}
interface PriceApiResponse {
price: PriceData;
attachment: AttachmentData;
category: string;
}
export default function Price() {
const [priceData, setPriceData] = useState<PriceApiResponse | null>(null);
const [loading, setLoading] = useState(true);
const [categoryTitle, setCategoryTitle] = useState<string | null>(null);
useEffect(() => {
setLoading(true);
fetch('/api/price')
.then(response => response.json())
.then(data => {
setPriceData(data);
if (data.category) {
setCategoryTitle(data.category);
}
setLoading(false);
})
.catch(error => {
console.error('Error fetching price data:', error);
setLoading(false);
});
}, []);
const handleDownload = () => {
if (priceData?.attachment?.file_path) {
const link = document.createElement('a');
link.href = priceData.attachment.file_path;
link.download = priceData.attachment.file_name || 'cennik.pdf';
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
}
};
return (
<AppLayout breadcrumbs={getBreadcrumbs(categoryTitle)}>
<Head title="Cennik badań diagnostycznych" />
<div>
<main className="bg-background max-w-screen-lg mx-auto px-4 xl:px-0 pt-8 pb-12">
<div className="flex justify-between items-center mb-6">
<h1 className="text-3xl font-bold">Cennik badań diagnostycznych</h1>
{priceData?.attachment && (
<Button
onClick={handleDownload}
className="flex items-center gap-2"
>
<Download className="h-4 w-4" />
Pobierz PDF
</Button>
)}
</div>
{loading ? (
<div className="flex justify-center items-center h-64">
<div className="text-lg">Wczytywanie...</div>
</div>
) : !priceData?.attachment ? (
<div className="flex justify-center items-center h-64">
<div className="text-lg">Cennik nie jest obecnie dostępny.</div>
</div>
) : (
<div className="bg-white rounded-lg shadow-md overflow-hidden">
<object
data={priceData.attachment.file_path}
type="application/pdf"
className="w-full h-[800px]"
>
<div className="flex flex-col items-center justify-center p-8 text-center">
<p className="mb-4">Twoja przeglądarka nie obsługuje bezpośredniego wyświetlania plików PDF.</p>
<Button onClick={handleDownload}>Pobierz PDF</Button>
</div>
</object>
</div>
)}
</main>
</div>
</AppLayout>
);
}

View File

@@ -0,0 +1,190 @@
import { Head, Link } from '@inertiajs/react';
import { useEffect, useState } from 'react';
import { type BreadcrumbItem } from '@/types';
import AppLayout from '@/layouts/app-layout';
import axios from 'axios';
import { SingleImageLightbox, ImageLightbox } from '@/components/image-lightbox';
import AttachmentsList from '@/components/attachments-list';
interface Photo {
id: number;
image_name: string;
image_path: string;
image_size: number;
image_type: string;
created_at: string;
updated_at: string;
}
interface Attachment {
id: number;
file_name: string;
file_path: string;
file_size: number;
file_type: string;
created_at: string;
updated_at: string;
}
interface ProjectType {
id: number;
title: string;
slug: string;
created_at: string;
updated_at: string;
}
interface Project {
id: number;
title: string;
slug: string;
body: string;
logo: string;
active: boolean;
published_at: string;
created_at: string;
updated_at: string;
user_id: number;
projectTypes: ProjectType[];
photos?: Photo[];
attachments?: Attachment[];
}
interface ProjectApiResponse {
project: Project;
projectType: ProjectType;
}
interface ProjectDetailsProps {
typeSlug: string;
projectSlug: string;
}
export default function ProjectDetails({ typeSlug, projectSlug }: ProjectDetailsProps) {
const [projectData, setProjectData] = useState<ProjectApiResponse | null>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
setLoading(true);
fetch(`/api/projects/${encodeURIComponent(typeSlug)}/${encodeURIComponent(projectSlug)}`)
.then(response => response.json())
.then(data => {
setProjectData(data);
setLoading(false);
})
.catch(error => {
console.error('Error fetching project details:', error);
setProjectData(null);
setLoading(false);
});
}, [typeSlug, projectSlug]);
const truncateText = (text: string, maxLength: number): string => {
if (text.length <= maxLength) return text;
return text.slice(0, maxLength) + '…';
};
const getBreadcrumbs = (): BreadcrumbItem[] => [
{
title: 'Strona Główna',
href: '/',
},
{
title: 'Projekty',
href: '#',
disabled: true,
},
{
title: projectData?.projectType?.title || typeSlug.replace(/-/g, ' '),
href: `/projekty/${typeSlug}`,
},
{
title: truncateText(projectData?.project?.title || projectSlug.replace(/-/g, ' '), 35),
href: `/projekty/${typeSlug}/${projectSlug}`,
},
];
return (
<AppLayout breadcrumbs={getBreadcrumbs()}>
<Head title={`${truncateText(projectData?.project?.title || 'Projekt', 35)}`} />
<div>
<main className="bg-background max-w-screen-lg mx-auto px-4 xl:px-0 pt-8 pb-12 min-h-[75vh]">
{loading ? (
<div className="flex justify-center items-center h-64">
<div className="text-lg">Wczytywanie zawartości...</div>
</div>
) : !projectData || !projectData.project ? (
<div className="flex justify-center items-center h-64">
<div className="text-lg">Nie znaleziono projektu.</div>
</div>
) : (
<div className="mb-8">
{projectData.project.logo && (
<div className="flex justify-center mb-8">
<img
src={projectData.project.logo}
alt={projectData.project.title}
className="max-h-40 object-contain"
/>
</div>
)}
<div className="max-w-3xl mx-auto">
<h1 className="text-3xl font-bold mb-4 text-center">{projectData.project.title}</h1>
{projectData.project.projectTypes && projectData.project.projectTypes.length > 0 && (
<div className="flex flex-wrap justify-center gap-2 mb-4">
{projectData.project.projectTypes.map(type => (
<Link
key={type.id}
href={`/projekty/${type.slug}`}
className="px-3 py-1 text-sm bg-muted rounded-full hover:bg-muted/80"
>
{type.title}
</Link>
))}
</div>
)}
<div className="text-foreground content" dangerouslySetInnerHTML={{ __html: projectData.project.body }}></div>
</div>
{projectData.project.photos && projectData.project.photos.length > 0 && (
<div className="mt-8 max-w-4xl mx-auto">
<h2 className="text-2xl font-semibold mb-4 text-center">Galeria</h2>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{projectData.project.photos.map((photo, index) => (
<ImageLightbox
key={photo.id}
images={projectData.project.photos as Array<{
id: number;
image_path: string;
image_desc?: string;
}>}
initialIndex={index}
/>
))}
</div>
</div>
)}
<AttachmentsList attachments={projectData.project.attachments || []} />
<div className="mt-8 pt-4 border-t border-border max-w-3xl mx-auto text-center">
<Link
href={`/projekty/${typeSlug}`}
className="inline-flex items-center text-primary hover:underline"
>
<svg xmlns="http://www.w3.org/2000/svg" className="h-4 w-4 mr-1" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
</svg>
Powrót do listy projektów
</Link>
</div>
</div>
)}
</main>
</div>
</AppLayout>
);
}

View File

@@ -0,0 +1,185 @@
import { Head, Link } from '@inertiajs/react';
import { useEffect, useState } from 'react';
import { type BreadcrumbItem } from '@/types';
import AppLayout from '@/layouts/app-layout';
interface ProjectType {
id: number;
title: string;
slug: string;
created_at: string;
updated_at: string;
}
interface Project {
id: number;
title: string;
slug: string;
body: string;
logo: string;
active: boolean;
published_at: string;
created_at: string;
updated_at: string;
user_id: number;
projectTypes: ProjectType[];
}
interface ProjectsApiResponse {
projects: Project[];
projectType: ProjectType | null;
}
interface ProjectsPageProps {
typeSlug?: string;
}
export default function Projects({ typeSlug }: ProjectsPageProps) {
const [projectsData, setProjectsData] = useState<ProjectsApiResponse | null>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
setLoading(true);
const url = `/api/projects/${encodeURIComponent(typeSlug || '')}`;
fetch(url)
.then(response => response.json())
.then(data => {
setProjectsData(data);
setLoading(false);
})
.catch(error => {
console.error('Error fetching projects:', error);
setProjectsData(null);
setLoading(false);
});
}, [typeSlug]);
const breadcrumbs: BreadcrumbItem[] = [
{
title: 'Strona Główna',
href: '/',
},
{
title: 'Projekty',
href: '#',
disabled: true,
},
{
title: projectsData?.projectType?.title || 'Projekty',
href: typeSlug ? `/projekty/${typeSlug}` : '/projekty',
},
];
return (
<AppLayout breadcrumbs={breadcrumbs}>
<Head title={`${projectsData?.projectType?.title || 'Projekty'}`} />
<div>
<main className="bg-background max-w-screen-xl mx-auto px-4 xl:px-0 pt-8 pb-12">
<h1 className="text-3xl font-bold mb-6">{projectsData?.projectType?.title || 'Projekty'}</h1>
{projectsData?.projectType === null && projectsData?.projects && projectsData.projects.length > 0 && (
<div className="flex flex-wrap gap-2 mb-6">
<Link
href="/projekty"
className={`px-3 py-1 text-sm rounded-full ${!typeSlug ? 'bg-primary text-primary-foreground' : 'bg-muted hover:bg-muted/80'}`}
>
Wszystkie
</Link>
{Array.from(new Set(
projectsData.projects.flatMap(project =>
project.projectTypes.map(type => JSON.stringify(type))
)
)).map(typeString => {
const type = JSON.parse(typeString) as ProjectType;
return (
<Link
key={type.id}
href={`/projekty/${type.slug}`}
className={`px-3 py-1 text-sm rounded-full ${typeSlug === type.slug ? 'bg-primary text-primary-foreground' : 'bg-muted hover:bg-muted/80'}`}
>
{type.title}
</Link>
);
})}
</div>
)}
{loading ? (
<div className="flex justify-center items-center h-64">
<div className="text-lg">Wczytywanie...</div>
</div>
) : !projectsData || !projectsData.projects || projectsData.projects.length === 0 ? (
<div className="flex justify-center items-center h-64">
<div className="text-lg">Brak projektów.</div>
</div>
) : (
<div>
<div className="space-y-8 mb-8">
{projectsData.projects.map((project) => {
function stripHtml(html: string) {
const htmlWithSpaces = html
.replace(/<br\s*\/?>/gi, ' ')
.replace(/<\/p><p>/gi, ' ')
.replace(/<\/div><div>/gi, ' ')
.replace(/<\/h[1-6]><h[1-6]>/gi, ' ')
.replace(/<\/li><li>/gi, ' ');
const div = document.createElement('div');
div.innerHTML = htmlWithSpaces;
return (div.textContent || div.innerText || '')
.replace(/\s+/g, ' ')
.trim();
}
const plainText = stripHtml(project.body);
const shortText = plainText.length > 200
? plainText.slice(0, 200).replace(/\s+$/, '') + '…'
: plainText;
return (
<div key={project.id} className="p-6 bg-card rounded-lg shadow-sm">
{project.logo && (
<div className="flex justify-center mb-4">
<img
src={project.logo}
alt={project.title}
className="max-h-24 object-contain"
/>
</div>
)}
<h2 className="text-2xl font-bold mb-2">{project.title}</h2>
{project.projectTypes && project.projectTypes.length > 0 && (
<div className="flex flex-wrap gap-1 mb-3">
{project.projectTypes.map(type => (
<Link
key={type.id}
href={`/projekty/${type.slug}`}
className="px-2 py-0.5 text-xs bg-muted rounded-full hover:bg-muted/80"
>
{type.title}
</Link>
))}
</div>
)}
<div className="text-foreground mb-4">{shortText}</div>
<Link
href={`/projekty/${project.projectTypes && project.projectTypes.length > 0 ? project.projectTypes[0].slug : (typeSlug || '')}/${project.slug}`}
className="inline-block text-primary hover:underline font-semibold"
>
Czytaj więcej
</Link>
</div>
);
})}
</div>
</div>
)}
</main>
</div>
</AppLayout>
);
}

View File

@@ -0,0 +1,108 @@
import { Head } from '@inertiajs/react';
import { useEffect, useState } from 'react';
import { type BreadcrumbItem } from '@/types';
import AppLayout from '@/layouts/app-layout';
import { Phone } from 'lucide-react';
interface TelephoneData {
name: string;
number: string;
}
interface TelephoneEntry {
type: string;
data: TelephoneData;
}
interface TelephoneItem {
id: number;
section: string;
telephones: TelephoneEntry[];
sort_order: number;
created_at: string;
updated_at: string;
}
interface TelephonesApiResponse {
telephones: TelephoneItem[];
}
const getBreadcrumbs = (): BreadcrumbItem[] => [
{
title: 'Strona Główna',
href: '/',
},
{
title: 'Telefony',
href: '/telefony',
},
];
export default function Telephones() {
const [telephonesData, setTelephonesData] = useState<TelephonesApiResponse | null>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
setLoading(true);
fetch('/api/telephones')
.then(response => response.json())
.then(data => {
setTelephonesData(data);
setLoading(false);
})
.catch(error => {
console.error('Error fetching telephones:', error);
setTelephonesData(null);
setLoading(false);
});
}, []);
return (
<AppLayout breadcrumbs={getBreadcrumbs()}>
<Head title="Telefony" />
<div>
<main className="bg-background max-w-screen-lg mx-auto px-4 xl:px-0 pt-8 pb-12 min-h-[75vh]">
<h1 className="text-3xl font-bold mb-8 text-center">Telefony</h1>
{loading ? (
<div className="flex justify-center items-center h-64">
<div className="text-lg">Wczytywanie zawartości...</div>
</div>
) : !telephonesData || !telephonesData.telephones || telephonesData.telephones.length === 0 ? (
<div className="flex justify-center items-center h-64">
<div className="text-lg">Brak danych.</div>
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 gap-8">
{telephonesData.telephones.map((item) => (
<div key={item.id} className="bg-card rounded-lg shadow-sm overflow-hidden">
<div className="bg-foreground dark:bg-primary contrast:bg-primary p-4">
<h2 className="text-xl font-semibold text-primary-foreground">{item.section}</h2>
</div>
<div className="p-6">
<ul className="space-y-4">
{item.telephones.map((telephone, index) => (
<li key={index} className="flex items-center justify-between group">
<div className="flex items-center">
<Phone className="h-5 w-5 mr-3 text-primary" />
<span className="font-medium">{telephone.data.name}</span>
</div>
<a
href={`tel:${telephone.data.number.replace(/\s+/g, '')}`}
className="text-primary font-semibold hover:underline transition-colors"
>
{telephone.data.number}
</a>
</li>
))}
</ul>
</div>
</div>
))}
</div>
)}
</main>
</div>
</AppLayout>
);
}

30
resources/js/ssr.tsx Normal file
View File

@@ -0,0 +1,30 @@
import { createInertiaApp } from '@inertiajs/react';
import createServer from '@inertiajs/react/server';
import { resolvePageComponent } from 'laravel-vite-plugin/inertia-helpers';
import ReactDOMServer from 'react-dom/server';
import { type RouteName, route } from 'ziggy-js';
const appName = import.meta.env.VITE_APP_NAME || 'Laravel';
createServer((page) =>
createInertiaApp({
page,
render: ReactDOMServer.renderToString,
title: (title) => `${title} - ${appName}`,
resolve: (name) => resolvePageComponent(`./pages/${name}.tsx`, import.meta.glob('./pages/**/*.tsx')),
setup: ({ App, props }) => {
/* eslint-disable */
// @ts-expect-error
global.route<RouteName> = (name, params, absolute) =>
route(name, params as any, absolute, {
// @ts-expect-error
...page.props.ziggy,
// @ts-expect-error
location: new URL(page.props.ziggy.location),
});
/* eslint-enable */
return <App {...props} />;
},
}),
);

5
resources/js/types/global.d.ts vendored Normal file
View File

@@ -0,0 +1,5 @@
import type { route as routeFn } from 'ziggy-js';
declare global {
const route: typeof routeFn;
}

30
resources/js/types/index.d.ts vendored Normal file
View File

@@ -0,0 +1,30 @@
import { LucideIcon } from 'lucide-react';
import type { Config } from 'ziggy-js';
export interface BreadcrumbItem {
title: string;
href: string;
current?: boolean;
className?: string;
disabled?: boolean;
}
export interface NavGroup {
title: string;
items: NavItem[];
}
export interface NavItem {
title: string;
href: string;
icon?: LucideIcon | null;
isActive?: boolean;
}
export interface SharedData {
name: string;
quote: { message: string; author: string };
ziggy: Config & { location: string };
sidebarOpen: boolean;
[key: string]: unknown;
}

1
resources/js/types/vite-env.d.ts vendored Normal file
View File

@@ -0,0 +1 @@
/// <reference types="vite/client" />

View File

@@ -0,0 +1,57 @@
<!DOCTYPE html>
<html lang="{{ str_replace('_', '-', app()->getLocale()) }}" @class(['dark' => ($appearance ?? 'system') == 'dark'])>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="apple-touch-icon" sizes="180x180" href="/storage/favicon/apple-touch-icon.png">
<link rel="icon" type="image/png" sizes="32x32" href="/storage/favicon/favicon-32x32.png">
<link rel="icon" type="image/png" sizes="16x16" href="/storage/favicon/favicon-16x16.png">
<link rel="manifest" href="/storage/favicon/site.webmanifest">
<link rel="shortcut icon" href="/storage/favicon.ico">
<meta name="theme-color" content="#ffffff">
{{-- Inline script to detect system dark mode preference and apply it immediately --}}
<script>
(function() {
const appearance = '{{ $appearance ?? "system" }}';
if (appearance === 'system') {
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
if (prefersDark) {
document.documentElement.classList.add('dark');
}
}
})();
</script>
{{-- Inline style to set the HTML background color based on our theme in app.css --}}
<style>
html {
background-color: oklch(0.9897 0 0);
}
html.dark {
background-color: oklch(0.24 0.04 265);
}
html.contrast {
background-color: oklch(0 0 0);
}
</style>
<title inertia>{{ config('app.name', 'Szpital Oborniki') }}</title>
<!-- CSRF Token -->
<meta name="csrf-token" content="{{ csrf_token() }}">
@routes
@viteReactRefresh
@vite(['resources/js/app.tsx', "resources/js/pages/{$page['component']}.tsx"])
@inertiaHead
</head>
<body class="font-sans antialiased">
@inertia
</body>
</html>

View File

@@ -0,0 +1,91 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Wiadomość z formularza kontaktowego</title>
<style>
body {
font-family: Arial, sans-serif;
line-height: 1.6;
color: #333;
padding: 20px;
}
.container {
max-width: 600px;
margin: 0 auto;
border: 1px solid #ddd;
border-radius: 5px;
padding: 20px;
}
.header {
background-color: #f8f9fa;
padding: 15px;
margin-bottom: 20px;
border-radius: 5px;
}
.content {
padding: 15px;
}
.footer {
margin-top: 20px;
padding-top: 20px;
border-top: 1px solid #ddd;
font-size: 12px;
color: #777;
}
.field {
margin-bottom: 15px;
}
.field-label {
font-weight: bold;
margin-bottom: 5px;
}
.field-value {
padding: 10px;
background-color: #f8f9fa;
border-radius: 5px;
}
</style>
</head>
<body>
<div class="container">
<div class="header">
<h2>Nowa wiadomość z formularza kontaktowego</h2>
</div>
<div class="content">
<div class="field">
<div class="field-label">Imię i nazwisko:</div>
<div class="field-value">{{ $name }}</div>
</div>
<div class="field">
<div class="field-label">Adres e-mail:</div>
<div class="field-value">{{ $email }}</div>
</div>
@if($phone)
<div class="field">
<div class="field-label">Telefon:</div>
<div class="field-value">{{ $phone }}</div>
</div>
@endif
<div class="field">
<div class="field-label">Temat:</div>
<div class="field-value">{{ $subject }}</div>
</div>
<div class="field">
<div class="field-label">Treść wiadomości:</div>
<div class="field-value">{{ $messageContent }}</div>
</div>
</div>
<div class="footer">
<p>Ta wiadomość została wysłana automatycznie z formularza kontaktowego na stronie Samodzielny Publiczny Zakład Opieki Zdrowotnej w Obornikach (www.szpital.oborniki.info).</p>
</div>
</div>
</body>
</html>

View File

@@ -0,0 +1,9 @@
@php
use Illuminate\Support\Facades\Storage;
$files = Storage::disk('public')->files('header-logo');
$lastFile = !empty($files) ? last($files) : 'header-logo/logo.png';
@endphp
<div class="flex items-center gap-2">
<img src="{{ asset('storage/' . $lastFile) }}" alt="Logo Szpitala" class="h-10">
<h1 class="text-2xl font-semibold">Szpital Oborniki</h1>
</div>

View File

@@ -0,0 +1,7 @@
<x-filament-panels::page>
<x-filament-panels::form wire:submit="save">
{{ $this->form }}
<x-filament-panels::form.actions :actions="$this->getFormActions()" />
<x-filament-actions::modals />
</x-filament-panels::form>
</x-filament-panels::page>

View File

@@ -0,0 +1,58 @@
<x-filament-panels::page>
<div class="flex flex-col items-start justify-start">
<h1 class="text-2xl font-bold tracking-tight text-gray-950 dark:text-white mb-4">Informacje o serwerze</h1>
<h2 class="text-xl font-semibold tracking-tight text-gray-950 dark:text-white">Aplikacja</h2>
<ul class="mb-4 w-fit text-gray-950 list-disc list-inside dark:text-gray-400">
<li class="text-gray-950 dark:text-gray-400">Nazwa aplikacji: {{ config('app.name') }}</li>
<li class="text-gray-950 dark:text-gray-400">URL aplikacji: {{ config('app.url') }}</li>
<li class="text-gray-950 dark:text-gray-400">Środowisko: {{ config('app.env') }}</li>
<li class="text-gray-950 dark:text-gray-400">Debugowanie: {{ config('app.debug') }}</li>
<li class="text-gray-950 dark:text-gray-400">Język aplikacji: {{ config('app.locale') }}</li>
</ul>
<h2 class="text-xl font-semibold tracking-tight text-gray-950 dark:text-white">Biblioteki (npm)</h2>
<ul class="mb-4 w-fit text-gray-950 list-disc list-inside dark:text-gray-400">
@foreach ($npmPackages as $package)
<li class="text-gray-950 dark:text-gray-400" style="color: {{ $package['is_up_to_date'] ? 'green' : 'red' }}">{{ $package['name'] }}{{ $package['current_version'] ? ', Wersja: ' . $package['current_version'] : null }}{{ !$package['is_up_to_date'] ? ' -> Nowsza wersja: ' . $package['latest_version'] : null }}</li>
@endforeach
</ul>
<h2 class="text-xl font-semibold tracking-tight text-gray-950 dark:text-white">Biblioteki (composer)</h2>
<ul class="mb-4 w-fit text-gray-950 list-disc list-inside dark:text-gray-400">
@foreach ($composerPackages as $package)
<li class="text-gray-950 dark:text-gray-400" style="color: {{ $package['is_up_to_date'] ? 'green' : 'red' }}">{{ $package['name'] }}{{ $package['current_version'] ? ', Wersja: ' . $package['current_version'] : null }}{{ !$package['is_up_to_date'] ? ' -> Nowsza wersja: ' . $package['latest_version'] : null }}</li>
@endforeach
</ul>
<h2 class="text-xl font-semibold tracking-tight text-gray-950 dark:text-white">PHP {{ phpversion() }}</h2>
<?php
$json = file_get_contents('https://www.php.net/releases/?json');
$data = json_decode($json, true);
if (is_array($data) && !empty($data)) {
$versions = array_keys($data);
usort($versions, 'version_compare');
$latest = end($versions);
$latestVersionObj = $data[$latest];
$latestVersionField = $latestVersionObj['version'] ?? $latest;
echo "<p class='font-semibold underline text-gray-950 dark:text-gray-400'>Najnowsza wersja PHP: $latestVersionField</p>";
} else {
echo "<p class='font-semibold underline text-gray-950 dark:text-gray-400'>Nie udało się pobrać danych o wersjach PHP.</p>";
}
?>
<p class="text-lg text-gray-950 dark:text-white">Załadowane moduły:</p>
<ul class="mb-4 w-fit text-gray-950 list-disc list-inside dark:text-gray-400">
<?php
$modules = get_loaded_extensions();
foreach ($modules as $module) {
echo '<li class="text-gray-950 dark:text-gray-400">' . $module . ', Wersja: ' . phpversion($module) . "</li>";
}
?>
</ul>
</div>
</x-filament-panels::page>