Compare commits

...

11 Commits

Author SHA1 Message Date
Kenan Alić
6c0ca89fd2 Post-release styling pass 2025-10-24 02:40:26 +02:00
Kenan Alić
d1784542d3 Version 1.0 squash 2025-10-24 02:40:26 +02:00
Kenan Alić
7536290f23 Added shadcn dashboard block 2025-10-24 02:40:26 +02:00
Kenan Alić
29c2a9f3ef Added formatting support for ci 2025-10-24 02:40:26 +02:00
Kenan Alić
b8917772f0 Added shadcn 2025-10-24 02:40:26 +02:00
Kenan Alić
b1394e0bdb Added prettier-plugin-tailwindcss 2025-10-24 02:40:26 +02:00
Kenan Alić
4cf439c506 Added Tailwind CSS 2025-10-24 02:40:26 +02:00
Kenan Alić
0223b48da3 Added wouter for hash-based routing 2025-10-24 02:40:26 +02:00
Kenan Alić
1be1600a1a Tidied Vite react-ts template 2025-10-24 02:40:26 +02:00
Kenan Alić
c840d6d3ff Added Vite react-ts template 2025-10-24 02:40:26 +02:00
Kenan Alić
51922d103c Added license 2025-10-24 02:40:26 +02:00
51 changed files with 7181 additions and 0 deletions

View File

@@ -0,0 +1,55 @@
name: Format
defaults:
run:
shell: sh
on:
pull_request:
types: [opened, synchronize]
jobs:
format:
runs-on: lts-alpine
steps:
- name: Install checkout dependencies
run: apk add git
- name: Checkout
uses: actions/checkout@v5
with:
ref: ${{ github.head_ref }}
fetch-depth: 0
- name: Install dependencies
run: apk add pnpm
- name: Install packages
run: pnpm install --frozen-lockfile
working-directory: ./web
- name: Run prettier
run: pnpm exec prettier -w **/*
working-directory: ./web
- name: Commit formatting changes
run: |
set -e
# Check if there are any changes
if git diff --quiet ./web; then
exit 0
fi
# Preserve original commit author
ORIGINAL_AUTHOR=$(git log -1 --format='%an <%ae>')
# Amend commit with formatting changes
git config user.name "Gitea Actions"
git config user.email "actions@gitea.local"
git add ./web
git commit --amend --no-edit --author="$ORIGINAL_AUTHOR"
# Force push to PR branch
git push --force-with-lease origin ${{ github.head_ref }}

7
license.txt Normal file
View File

@@ -0,0 +1,7 @@
Copyright © 2025 "NABLA" d.o.o. Zenica
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.

508
schema/pb_schema.json Normal file
View File

@@ -0,0 +1,508 @@
[
{
"id": "pbc_3085411453",
"listRule": "",
"viewRule": "",
"createRule": null,
"updateRule": null,
"deleteRule": null,
"name": "rooms",
"type": "base",
"fields": [
{
"autogeneratePattern": "[a-z0-9]{15}",
"hidden": false,
"id": "text3208210256",
"max": 15,
"min": 15,
"name": "id",
"pattern": "^[a-z0-9]+$",
"presentable": false,
"primaryKey": true,
"required": true,
"system": true,
"type": "text"
},
{
"autogeneratePattern": "",
"hidden": false,
"id": "text1579384326",
"max": 0,
"min": 0,
"name": "name",
"pattern": "",
"presentable": true,
"primaryKey": false,
"required": true,
"system": false,
"type": "text"
},
{
"autogeneratePattern": "",
"hidden": false,
"id": "text2363381545",
"max": 0,
"min": 0,
"name": "type",
"pattern": "",
"presentable": true,
"primaryKey": false,
"required": false,
"system": false,
"type": "text"
},
{
"cascadeDelete": true,
"collectionId": "pbc_397918867",
"hidden": false,
"id": "relation4054224987",
"maxSelect": 1,
"minSelect": 0,
"name": "floor_plan",
"presentable": true,
"required": true,
"system": false,
"type": "relation"
},
{
"cascadeDelete": true,
"collectionId": "pbc_3866053794",
"hidden": false,
"id": "relation1337919823",
"maxSelect": 1,
"minSelect": 0,
"name": "company",
"presentable": true,
"required": true,
"system": false,
"type": "relation"
},
{
"hidden": false,
"id": "number320209986",
"max": 100,
"min": 0,
"name": "map_x",
"onlyInt": false,
"presentable": true,
"required": true,
"system": false,
"type": "number"
},
{
"hidden": false,
"id": "number1678849236",
"max": 100,
"min": 0,
"name": "map_y",
"onlyInt": false,
"presentable": true,
"required": true,
"system": false,
"type": "number"
},
{
"hidden": false,
"id": "autodate2990389176",
"name": "created",
"onCreate": true,
"onUpdate": false,
"presentable": false,
"system": false,
"type": "autodate"
},
{
"hidden": false,
"id": "autodate3332085495",
"name": "updated",
"onCreate": true,
"onUpdate": true,
"presentable": false,
"system": false,
"type": "autodate"
}
],
"indexes": [
"CREATE INDEX `idx_zsTeabn0ng` ON `rooms` (`floor_plan`)",
"CREATE INDEX `idx_huUSmuvpCm` ON `rooms` (`company`)"
],
"system": false
},
{
"id": "pbc_3866499052",
"listRule": "",
"viewRule": "",
"createRule": null,
"updateRule": null,
"deleteRule": null,
"name": "notices",
"type": "base",
"fields": [
{
"autogeneratePattern": "[a-z0-9]{15}",
"hidden": false,
"id": "text3208210256",
"max": 15,
"min": 15,
"name": "id",
"pattern": "^[a-z0-9]+$",
"presentable": false,
"primaryKey": true,
"required": true,
"system": true,
"type": "text"
},
{
"autogeneratePattern": "",
"hidden": false,
"id": "text724990059",
"max": 0,
"min": 0,
"name": "title",
"pattern": "",
"presentable": true,
"primaryKey": false,
"required": true,
"system": false,
"type": "text"
},
{
"convertURLs": false,
"hidden": false,
"id": "editor3065852031",
"maxSize": 0,
"name": "message",
"presentable": true,
"required": true,
"system": false,
"type": "editor"
},
{
"hidden": false,
"id": "select1655102503",
"maxSelect": 1,
"name": "priority",
"presentable": true,
"required": true,
"system": false,
"type": "select",
"values": [
"urgent",
"normal",
"info"
]
},
{
"hidden": false,
"id": "date261981154",
"max": "",
"min": "",
"name": "expires_at",
"presentable": true,
"required": true,
"system": false,
"type": "date"
},
{
"hidden": false,
"id": "autodate2990389176",
"name": "created",
"onCreate": true,
"onUpdate": false,
"presentable": false,
"system": false,
"type": "autodate"
},
{
"hidden": false,
"id": "autodate3332085495",
"name": "updated",
"onCreate": true,
"onUpdate": true,
"presentable": false,
"system": false,
"type": "autodate"
}
],
"indexes": [
"CREATE INDEX `idx_KYJQu2mFEa` ON `notices` (`created`) DESC",
"CREATE INDEX `idx_MWJUrYaDZ8` ON `notices` (`expires_at`)"
],
"system": false
},
{
"id": "pbc_397918867",
"listRule": "",
"viewRule": "",
"createRule": null,
"updateRule": null,
"deleteRule": null,
"name": "floor_plans",
"type": "base",
"fields": [
{
"autogeneratePattern": "[a-z0-9]{15}",
"hidden": false,
"id": "text3208210256",
"max": 15,
"min": 15,
"name": "id",
"pattern": "^[a-z0-9]+$",
"presentable": false,
"primaryKey": true,
"required": true,
"system": true,
"type": "text"
},
{
"autogeneratePattern": "",
"hidden": false,
"id": "text3192247854",
"max": 0,
"min": 0,
"name": "floor",
"pattern": "",
"presentable": true,
"primaryKey": false,
"required": true,
"system": false,
"type": "text"
},
{
"autogeneratePattern": "",
"hidden": false,
"id": "text3180636438",
"max": 0,
"min": 0,
"name": "annex",
"pattern": "",
"presentable": true,
"primaryKey": false,
"required": true,
"system": false,
"type": "text"
},
{
"hidden": false,
"id": "file3391890175",
"maxSelect": 1,
"maxSize": 0,
"mimeTypes": [
"image/svg+xml"
],
"name": "svg_file",
"presentable": true,
"protected": false,
"required": true,
"system": false,
"thumbs": [],
"type": "file"
},
{
"hidden": false,
"id": "number1169138922",
"max": null,
"min": 0,
"name": "sort_order",
"onlyInt": true,
"presentable": true,
"required": true,
"system": false,
"type": "number"
},
{
"hidden": false,
"id": "autodate2990389176",
"name": "created",
"onCreate": true,
"onUpdate": false,
"presentable": false,
"system": false,
"type": "autodate"
},
{
"hidden": false,
"id": "autodate3332085495",
"name": "updated",
"onCreate": true,
"onUpdate": true,
"presentable": false,
"system": false,
"type": "autodate"
}
],
"indexes": [
"CREATE UNIQUE INDEX `idx_2wui8ye9ED` ON `floor_plans` (\n `floor`,\n `annex`\n)",
"CREATE INDEX `idx_mXHeuKQiOj` ON `floor_plans` (`sort_order`)"
],
"system": false
},
{
"id": "pbc_3866053794",
"listRule": "",
"viewRule": "",
"createRule": null,
"updateRule": "@request.body.logo:isset = false &&\n\n@request.body.short_name:isset = false &&\n@request.body.full_name:isset = false &&\n@request.body.industry:isset = false &&\n@request.body.status:isset = true &&\n\n@request.body.floor:isset = false &&\n@request.body.room:isset = false",
"deleteRule": null,
"name": "companies",
"type": "base",
"fields": [
{
"autogeneratePattern": "[a-z0-9]{15}",
"hidden": false,
"id": "text3208210256",
"max": 15,
"min": 15,
"name": "id",
"pattern": "^[a-z0-9]+$",
"presentable": false,
"primaryKey": true,
"required": true,
"system": true,
"type": "text"
},
{
"hidden": false,
"id": "file3834550803",
"maxSelect": 1,
"maxSize": 0,
"mimeTypes": [
"image/png",
"image/jpeg"
],
"name": "logo",
"presentable": true,
"protected": false,
"required": false,
"system": false,
"thumbs": [],
"type": "file"
},
{
"autogeneratePattern": "",
"hidden": false,
"id": "text1055174803",
"max": 0,
"min": 0,
"name": "short_name",
"pattern": "",
"presentable": true,
"primaryKey": false,
"required": true,
"system": false,
"type": "text"
},
{
"autogeneratePattern": "",
"hidden": false,
"id": "text3687080900",
"max": 0,
"min": 0,
"name": "full_name",
"pattern": "",
"presentable": true,
"primaryKey": false,
"required": true,
"system": false,
"type": "text"
},
{
"hidden": false,
"id": "select2063623452",
"maxSelect": 1,
"name": "status",
"presentable": false,
"required": true,
"system": false,
"type": "select",
"values": [
"present",
"absent"
]
},
{
"hidden": false,
"id": "autodate2990389176",
"name": "created",
"onCreate": true,
"onUpdate": false,
"presentable": false,
"system": false,
"type": "autodate"
},
{
"hidden": false,
"id": "autodate3332085495",
"name": "updated",
"onCreate": true,
"onUpdate": true,
"presentable": false,
"system": false,
"type": "autodate"
}
],
"indexes": [
"CREATE UNIQUE INDEX `idx_YcZEbYalR7` ON `companies` (`short_name`)"
],
"system": false
},
{
"id": "pbc_2125634568",
"listRule": "",
"viewRule": "",
"createRule": null,
"updateRule": null,
"deleteRule": null,
"name": "about",
"type": "base",
"fields": [
{
"autogeneratePattern": "[a-z0-9]{15}",
"hidden": false,
"id": "text3208210256",
"max": 15,
"min": 15,
"name": "id",
"pattern": "^[a-z0-9]+$",
"presentable": false,
"primaryKey": true,
"required": true,
"system": true,
"type": "text"
},
{
"convertURLs": false,
"hidden": false,
"id": "editor4274335913",
"maxSize": 0,
"name": "content",
"presentable": false,
"required": false,
"system": false,
"type": "editor"
},
{
"hidden": false,
"id": "autodate2990389176",
"name": "created",
"onCreate": true,
"onUpdate": false,
"presentable": false,
"system": false,
"type": "autodate"
},
{
"hidden": false,
"id": "autodate3332085495",
"name": "updated",
"onCreate": true,
"onUpdate": true,
"presentable": false,
"system": false,
"type": "autodate"
}
],
"indexes": [],
"system": false
}
]

24
web/.gitignore vendored Normal file
View File

@@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

1
web/.npmrc Normal file
View File

@@ -0,0 +1 @@
node-linker = hoisted

4
web/.prettierrc Normal file
View File

@@ -0,0 +1,4 @@
{
"printWidth": 120,
"plugins": ["prettier-plugin-tailwindcss"]
}

22
web/components.json Normal file
View File

@@ -0,0 +1,22 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "new-york",
"rsc": false,
"tsx": true,
"tailwind": {
"config": "",
"css": "src/styles/tailwind.css",
"baseColor": "neutral",
"cssVariables": true,
"prefix": ""
},
"iconLibrary": "lucide",
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
"ui": "@/components/ui",
"lib": "@/lib",
"hooks": "@/hooks"
},
"registries": {}
}

23
web/eslint.config.js Normal file
View File

@@ -0,0 +1,23 @@
import js from '@eslint/js'
import globals from 'globals'
import reactHooks from 'eslint-plugin-react-hooks'
import reactRefresh from 'eslint-plugin-react-refresh'
import tseslint from 'typescript-eslint'
import { defineConfig, globalIgnores } from 'eslint/config'
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
js.configs.recommended,
tseslint.configs.recommended,
reactHooks.configs['recommended-latest'],
reactRefresh.configs.vite,
],
languageOptions: {
ecmaVersion: 2020,
globals: globals.browser,
},
},
])

20
web/index.html Normal file
View File

@@ -0,0 +1,20 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="author" content="Kenan Alić" />
<meta name="author" content="kalic@nabla.ba" />
<link rel="icon" href="data:," />
<title>Web</title>
</head>
<body>
<noscript>This site requires JavaScript to operate. Please enable it in your browser settings.</noscript>
<div id="root"></div>
<script type="module" src="/src/vite-root.tsx"></script>
</body>
</html>

52
web/package.json Normal file
View File

@@ -0,0 +1,52 @@
{
"name": "web",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"lint": "eslint .",
"format": "prettier -w **/*",
"preview": "vite preview"
},
"dependencies": {
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-separator": "^1.1.7",
"@radix-ui/react-slot": "^1.2.3",
"@radix-ui/react-tooltip": "^1.2.8",
"@tailwindcss/vite": "^4.1.13",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"lucide-react": "^0.544.0",
"pocketbase": "^0.26.2",
"react": "^19.1.1",
"react-dom": "^19.1.1",
"tailwind-merge": "^3.3.1",
"tailwindcss": "^4.1.13",
"wouter": "^3.7.1"
},
"devDependencies": {
"@eslint/js": "^9.36.0",
"@tailwindcss/typography": "^0.5.19",
"@types/node": "^24.5.2",
"@types/react": "^19.1.13",
"@types/react-dom": "^19.1.9",
"@vitejs/plugin-react": "^5.0.3",
"eslint": "^9.36.0",
"eslint-plugin-react-hooks": "^5.2.0",
"eslint-plugin-react-refresh": "^0.4.20",
"globals": "^16.4.0",
"prettier": "^3.6.2",
"prettier-plugin-tailwindcss": "^0.6.14",
"tw-animate-css": "^1.3.8",
"typescript": "~5.8.3",
"typescript-eslint": "^8.44.0",
"vite": "npm:rolldown-vite@7.1.12"
},
"pnpm": {
"overrides": {
"vite": "npm:rolldown-vite@7.1.12"
}
}
}

3071
web/pnpm-lock.yaml generated Normal file
View File

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,93 @@
/**
* Company Card Component
*
* Displays a company card with logo, name, location, and status toggle.
* Used in the directory listing with horizontal layout optimized for kiosks.
*/
/* Icons */
import { Building2, DoorOpen, CheckCircle, CircleX } from "lucide-react";
/* UI Components */
import { Card, CardTitle, CardContent } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
/* Types & Constants */
import { type Company, statusVariants, statusBorderColors, statusLabels } from "@/types/company";
import type { Room, FloorPlan } from "@/types/floor-plan";
/* Utility helpers */
import { getFileUrl } from "@/lib/utils";
/* Constants */
import { LOCATION_LABELS } from "@/constants/ui-text";
/**
* Component props for CompanyCard
*
* @property company - Company data including logo, names, and status
* @property location - Room and floor plan location data, null if not assigned
* @property isUpdating - Whether the company status is currently being updated
* @property onStatusToggle - Callback fired when user toggles company presence status
*/
interface CompanyCardProps {
company: Company;
location: { room: Room; floorPlan: FloorPlan } | null;
isUpdating: boolean;
onStatusToggle: (company: Company) => void;
}
export function CompanyCard({ company, location, isUpdating, onStatusToggle }: CompanyCardProps) {
const StatusIcon = company.status === "present" ? CheckCircle : CircleX;
return (
<Card className={`relative ${statusBorderColors[company.status]}`}>
<CardContent className="flex gap-4 p-4">
<div className="flex-shrink-0">
<img
src={getFileUrl(company, company.logo)}
alt={company.short_name}
className="h-20 w-20 rounded-lg object-contain"
/>
</div>
<div className="flex min-w-0 flex-1 flex-col justify-center gap-1">
<CardTitle className="text-lg">{company.short_name}</CardTitle>
<p className="text-muted-foreground line-clamp-1 text-sm">{company.full_name}</p>
{location && (
<div className="text-muted-foreground flex items-center gap-2 text-sm">
<div className="flex items-center gap-1">
<Building2 className="h-4 w-4" />
<span>{location.floorPlan.floor}</span>
</div>
<span></span>
<div className="flex items-center gap-1">
<DoorOpen className="h-4 w-4" />
<span>
{LOCATION_LABELS.room} {location.room.name}
</span>
</div>
</div>
)}
</div>
<div className="flex flex-shrink-0 items-center">
<Button
variant="outline"
onClick={() => onStatusToggle(company)}
disabled={isUpdating}
className="h-20 w-20 p-0"
>
<StatusIcon className={`size-12 ${isUpdating ? "opacity-50" : ""}`} />
</Button>
</div>
<Badge variant={statusVariants[company.status]} className="absolute top-3 right-3">
{statusLabels[company.status]}
</Badge>
</CardContent>
</Card>
);
}

View File

@@ -0,0 +1,62 @@
/**
* Floor Plan Markers Component
*
* Renders company logo markers positioned on floor plan SVG.
* Uses percentage-based coordinates (0-100) for responsive positioning.
*/
/* Types */
import type { Room } from "@/types/floor-plan";
/* Utility helpers */
import { getFileUrl } from "@/lib/utils";
/**
* Component props for FloorPlanMarkers
*
* @property rooms - Array of room objects with coordinates and company relations
*/
interface FloorPlanMarkersProps {
rooms: Room[];
}
export function FloorPlanMarkers({ rooms }: FloorPlanMarkersProps) {
/**
* Filter rooms that should display markers on the floor plan
* Only show rooms that:
* 1. Have map coordinates (map_x, map_y) configured in PocketBase
* 2. Are currently occupied by a company (expand.company exists)
* Empty rooms or rooms without coordinates are not displayed
*/
const visibleRooms = rooms.filter(
(room) => room.map_x !== undefined && room.map_y !== undefined && room.expand?.company,
);
return (
<>
{visibleRooms.map((room) => (
<div
key={room.id}
className="absolute flex flex-col items-center gap-1"
style={{
left: `${room.map_x}%`,
top: `${room.map_y}%`,
transform: "translate(-50%, -50%)",
}}
>
<div className="flex h-12 w-12 items-center justify-center overflow-hidden rounded-lg border-2 border-white bg-white shadow-lg">
<img
src={getFileUrl(room.expand!.company!, room.expand!.company!.logo)}
alt={room.expand!.company!.short_name}
className="h-full w-full object-contain"
/>
</div>
<span className="rounded bg-white/90 px-2 py-0.5 text-xs font-medium shadow-sm">
{room.expand!.company!.short_name}
</span>
</div>
))}
</>
);
}

View File

@@ -0,0 +1,157 @@
/**
* Kiosk Layout Component
*
* Main layout wrapper for the business facility kiosk application.
* Provides navigation sidebar, page title, live clock, and content area.
*/
/* React hooks */
import { useState, useEffect } from "react";
/* Routing */
import { Link, useLocation } from "wouter";
/* Icons */
import { Megaphone, Building2, Map, Info } from "lucide-react";
/* UI Components */
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Separator } from "@/components/ui/separator";
/* Context */
import { useKioskData } from "@/contexts/kiosk-data-context";
/* Utility helpers */
import { isExpired } from "@/lib/utils";
/* Constants */
import { PAGE_TITLES } from "@/constants/ui-text";
/* Navigation button configuration */
interface NavButton {
icon: React.ComponentType<{ className?: string }>;
path: string;
}
/* Main navigation buttons displayed in the sidebar */
const navButtons: NavButton[] = [
{ icon: Megaphone, path: "/" },
{ icon: Building2, path: "/directory" },
{ icon: Map, path: "/navigation" },
{ icon: Info, path: "/about" },
];
/* Page title mapping for each route */
const pageTitles: Record<string, string> = {
"/": PAGE_TITLES.bulletin,
"/directory": PAGE_TITLES.directory,
"/navigation": PAGE_TITLES.navigation,
"/about": PAGE_TITLES.about,
};
export function Layout({ children }: { children: React.ReactNode }) {
const [location] = useLocation();
const [currentTime, setCurrentTime] = useState(new Date());
const { notices, companies } = useKioskData();
/**
* Calculate badge counts for navigation buttons
* Simple filter operations - no memoization needed as overhead exceeds benefit
*/
const activeNoticesCount = notices.filter((n) => !isExpired(n.expires_at)).length;
const presentCompaniesCount = companies.filter((c) => c.status === "present").length;
/* Get badge count for navigation button based on path */
const getBadgeCount = (path: string): number | undefined => {
if (path === "/") return activeNoticesCount;
if (path === "/directory") return presentCompaniesCount;
return undefined;
};
useEffect(() => {
/* Update time every minute */
const timer = setInterval(() => {
setCurrentTime(new Date());
}, 60000);
return () => clearInterval(timer);
}, []);
/**
* Formats time for display in header clock
*
* @param date - Date object to format
* @returns Formatted time string (e.g., "9:30")
*/
const formatTime = (date: Date) => {
return date.toLocaleTimeString(undefined, {
hour: "numeric",
minute: "2-digit",
});
};
/**
* Formats date for display in header clock
*
* @param date - Date object to format
* @returns Formatted date string (e.g., "ponedeljak, oktobar 21")
*/
const formatDate = (date: Date) => {
return date.toLocaleDateString(undefined, {
weekday: "long",
month: "long",
day: "numeric",
});
};
return (
<div className="flex h-screen w-full">
{/* Left sidebar navigation panel */}
<div className="flex items-center justify-center p-6">
<div className="flex flex-col gap-6">
{navButtons.map(({ icon: Icon, path }) => {
const isActive = location === path;
const badgeCount = getBadgeCount(path);
return (
<Link key={path} href={path}>
<Button variant={isActive ? "default" : "outline"} className="relative h-40 w-40">
<Icon className="size-16" />
{badgeCount !== undefined && badgeCount > 0 && (
<Badge className="absolute -top-3 -right-3 h-12 min-w-12 px-3 text-lg font-bold">
{badgeCount}
</Badge>
)}
</Button>
</Link>
);
})}
</div>
</div>
{/* Vertical divider between navigation and content */}
<Separator orientation="vertical" className="h-full" />
{/* Main content area */}
<div className="flex flex-1 flex-col">
{/* Header with page title and live clock */}
<div className="flex items-center justify-between p-6 pb-4">
<h1 className="text-4xl font-bold">{pageTitles[location] || "Page"}</h1>
<div className="text-muted-foreground flex flex-col items-end">
<div className="text-2xl font-semibold">{formatTime(currentTime)}</div>
<div className="text-base">{formatDate(currentTime)}</div>
</div>
</div>
{/* Separator below header */}
<Separator />
{/* Scrollable page content */}
<div className="flex-1 overflow-auto p-6">{children}</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,62 @@
/**
* Notice Card Component
*
* Displays a notice/announcement with priority styling and expiry information.
* Used on the bulletin board with automatic expiry detection and visual distinction.
*/
/* UI Components */
import { Card, CardHeader, CardTitle, CardAction, CardContent, CardFooter } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
/* Types & Constants */
import { type Notice, priorityVariants, priorityBorderColors, priorityLabels } from "@/types/notice";
/* Utility helpers */
import { formatPostedDate, isExpired } from "@/lib/utils";
/* Constants */
import { NOTICE_LABELS } from "@/constants/ui-text";
/**
* Component props for NoticeCard
*
* @property notice - Notice data including title, message, priority, and expiry date
*/
interface NoticeCardProps {
notice: Notice;
}
export function NoticeCard({ notice }: NoticeCardProps) {
const expired = isExpired(notice.expires_at);
const expiryDateStr = new Date(notice.expires_at).toLocaleDateString(undefined, {
month: "short",
day: "numeric",
year: "numeric",
});
return (
<Card className={`${priorityBorderColors[notice.priority]} ${expired ? "opacity-60" : ""}`}>
<CardHeader>
<CardTitle className="text-2xl">{notice.title}</CardTitle>
<CardAction>
<Badge variant={priorityVariants[notice.priority]}>{priorityLabels[notice.priority]}</Badge>
</CardAction>
</CardHeader>
<CardContent>
<div className="prose prose-base max-w-none" dangerouslySetInnerHTML={{ __html: notice.message }} />
</CardContent>
<CardFooter className="flex-row items-center gap-2">
<p className="text-muted-foreground text-sm">
{NOTICE_LABELS.posted}: {formatPostedDate(notice.created)}
</p>
<span className="text-muted-foreground"></span>
<p className={`text-sm ${expired ? "text-destructive" : "text-muted-foreground"}`}>
{expired ? NOTICE_LABELS.expired : NOTICE_LABELS.validUntil}: {expiryDateStr}
</p>
</CardFooter>
</Card>
);
}

View File

@@ -0,0 +1,69 @@
/**
* Room Info Panel Component
*
* Floating panel that displays selected room information.
* Appears at bottom-center of navigation screen when a room is selected.
*/
/* Icons */
import { Building2 } from "lucide-react";
/* UI Components */
import { Card, CardContent } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
/* Types */
import type { Room } from "@/types/floor-plan";
/* Constants */
import { LOCATION_LABELS } from "@/constants/ui-text";
/**
* Component props for RoomInfoPanel
*
* @property room - Room data including name, type, and occupying company
* @property onClose - Callback fired when user closes the panel
*/
interface RoomInfoPanelProps {
room: Room;
onClose: () => void;
}
export function RoomInfoPanel({ room, onClose }: RoomInfoPanelProps) {
return (
<div className="absolute bottom-4 left-1/2 z-10 -translate-x-1/2">
<Card className="border-primary shadow-lg">
<CardContent className="flex items-center gap-4 p-4">
<div className="flex flex-1 items-center gap-6">
<div className="flex items-center gap-2">
<Building2 className="text-muted-foreground h-5 w-5" />
<span className="font-semibold">{room.name}</span>
</div>
{room.type && (
<>
<span className="text-muted-foreground"></span>
<span className="text-muted-foreground">{room.type}</span>
</>
)}
{room.expand?.company && (
<>
<span className="text-muted-foreground"></span>
<span className="font-medium">{room.expand.company.short_name}</span>
<Badge>{LOCATION_LABELS.occupied}</Badge>
</>
)}
{!room.expand?.company && <Badge variant="secondary">{LOCATION_LABELS.available}</Badge>}
</div>
<Button variant="ghost" size="sm" onClick={onClose}>
{LOCATION_LABELS.close}
</Button>
</CardContent>
</Card>
</div>
);
}

View File

@@ -0,0 +1,36 @@
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-full 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-hidden",
{
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 dark:bg-destructive/60",
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,92 @@
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,52 @@
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 hover:bg-primary/90",
destructive:
"bg-destructive text-white 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 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",
"icon-sm": "size-8",
"icon-lg": "size-10",
},
},
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,56 @@
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(
"@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-2 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-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 CardAction({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-action"
className={cn("col-start-2 row-span-2 row-start-1 self-start justify-self-end", 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 [.border-t]:pt-6", className)} {...props} />
);
}
export { Card, CardHeader, CardFooter, CardTitle, CardAction, CardDescription, CardContent };

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(
"file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input 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,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"
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,103 @@
"use client";
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/50",
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,677 @@
"use client";
import * as React from "react";
import { Slot } from "@radix-ui/react-slot";
import { cva, type VariantProps } 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 SidebarContextProps = {
state: "expanded" | "collapsed";
open: boolean;
setOpen: (open: boolean) => void;
openMobile: boolean;
setOpenMobile: (open: boolean) => void;
isMobile: boolean;
toggleSidebar: () => void;
};
const SidebarContext = React.createContext<SidebarContextProps | 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<SidebarContextProps>(
() => ({
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}>
<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}
>
<SheetHeader className="sr-only">
<SheetTitle>Sidebar</SheetTitle>
<SheetDescription>Displays the mobile sidebar.</SheetDescription>
</SheetHeader>
<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
data-slot="sidebar-gap"
className={cn(
"relative 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
data-slot="sidebar-container"
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"
data-slot="sidebar-inner"
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("size-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 w-full flex-1 flex-col",
"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-2",
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,7 @@
import { cn } from "@/lib/utils";
function Skeleton({ className, ...props }: React.ComponentProps<"div">) {
return <div data-slot="skeleton" className={cn("bg-accent animate-pulse rounded-md", className)} {...props} />;
}
export { Skeleton };

View File

@@ -0,0 +1,46 @@
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 = 0,
children,
...props
}: React.ComponentProps<typeof TooltipPrimitive.Content>) {
return (
<TooltipPrimitive.Portal>
<TooltipPrimitive.Content
data-slot="tooltip-content"
sideOffset={sideOffset}
className={cn(
"bg-foreground text-background 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 w-fit origin-(--radix-tooltip-content-transform-origin) rounded-md px-3 py-1.5 text-xs text-balance",
className,
)}
{...props}
>
{children}
<TooltipPrimitive.Arrow className="bg-foreground fill-foreground 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,62 @@
/**
* UI Text Constants
*
* Centralized location for all user-facing text strings in Bosnian.
* Makes localization easier and reduces magic strings throughout the codebase.
*/
/* Page titles displayed in header */
export const PAGE_TITLES = {
bulletin: "Obavještenja",
directory: "Direktorij",
navigation: "Navigacija",
about: "Informacije",
} as const;
/* Common loading messages */
export const LOADING_MESSAGES = {
notices: "Učitavanje obavještenja...",
companies: "Učitavanje kompanija...",
floorPlans: "Učitavanje planova spratova...",
content: "Učitavanje sadržaja...",
} as const;
/* Common error messages */
export const ERROR_MESSAGES = {
generic: "Došlo je do greške pri učitavanju.",
} as const;
/* Empty state messages */
export const EMPTY_MESSAGES = {
noCompanies: "Nema kompanija koje odgovaraju kriterijumima pretraživanja.",
noFloorPlan: "Plan sprata nije trenutno dostupan.",
noContent: "Sadržaj trenutno nije dostupan.",
} as const;
/* Section headers */
export const SECTION_HEADERS = {
expiredNotices: "Istekla obavještenja",
absentCompanies: "Odsutne",
} as const;
/* Filter button labels */
export const FILTER_LABELS = {
all: "Sve",
present: "Prisutne",
absent: "Odsutne",
} as const;
/* Room and location labels */
export const LOCATION_LABELS = {
room: "Prostorija",
occupied: "Zauzeto",
available: "Dostupno",
close: "Zatvori",
} as const;
/* Notice card labels */
export const NOTICE_LABELS = {
posted: "Objavljeno",
validUntil: "Važi do",
expired: "Isteklo",
} as const;

View File

@@ -0,0 +1,113 @@
/**
* Kiosk Data Context
*
* Centralized data provider for all kiosk data (notices, companies, floor plans).
* Fetches all data once at app initialization and maintains real-time subscriptions.
* All components consume from this single source of truth.
*/
/* React hooks */
import { createContext, useContext, useState, useEffect, type ReactNode } from "react";
/* Types */
import type { About } from "@/types/about";
import type { Notice } from "@/types/notice";
import type { Company } from "@/types/company";
import type { FloorPlanWithRooms } from "@/types/floor-plan";
/* Services */
import { getAbout, subscribeToAbout } from "@/services/about";
import { getNotices, subscribeToNotices } from "@/services/notices";
import { getCompanies, subscribeToCompanies } from "@/services/companies";
import { getFloorPlans, subscribeToFloorPlans } from "@/services/floor-plans";
/* Context data structure */
interface KioskData {
about: About | null;
notices: Notice[];
companies: Company[];
floorPlans: FloorPlanWithRooms[];
loading: boolean;
error: Error | null;
}
/* Create context with default values */
const KioskDataContext = createContext<KioskData>({
about: null,
notices: [],
companies: [],
floorPlans: [],
loading: true,
error: null,
});
/* Provider component */
export function KioskDataProvider({ children }: { children: ReactNode }) {
const [about, setAbout] = useState<About | null>(null);
const [notices, setNotices] = useState<Notice[]>([]);
const [companies, setCompanies] = useState<Company[]>([]);
const [floorPlans, setFloorPlans] = useState<FloorPlanWithRooms[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<Error | null>(null);
useEffect(() => {
/* Fetch all data in parallel */
Promise.all([getAbout(), getNotices(), getCompanies(), getFloorPlans()])
.then(([fetchedAbout, fetchedNotices, fetchedCompanies, fetchedFloorPlans]) => {
setAbout(fetchedAbout);
setNotices(fetchedNotices);
setCompanies(fetchedCompanies);
setFloorPlans(fetchedFloorPlans);
setLoading(false);
})
.catch((err) => {
setError(err);
setLoading(false);
});
/* Set up real-time subscriptions */
const unsubscribeAbout = subscribeToAbout((updatedAbout) => {
setAbout(updatedAbout);
});
const unsubscribeNotices = subscribeToNotices((updatedNotices) => {
setNotices(updatedNotices);
});
const unsubscribeCompanies = subscribeToCompanies((updatedCompanies) => {
setCompanies(updatedCompanies);
});
const unsubscribeFloorPlans = subscribeToFloorPlans((updatedFloorPlans) => {
setFloorPlans(updatedFloorPlans);
});
/* Cleanup all subscriptions on unmount */
return () => {
unsubscribeAbout();
unsubscribeNotices();
unsubscribeCompanies();
unsubscribeFloorPlans();
};
}, []);
return (
<KioskDataContext.Provider value={{ about, notices, companies, floorPlans, loading, error }}>
{children}
</KioskDataContext.Provider>
);
}
/**
* Custom hook to access kiosk data from context
* Must be used within KioskDataProvider
*/
export function useKioskData() {
const context = useContext(KioskDataContext);
if (!context) {
throw new Error("useKioskData must be used within KioskDataProvider");
}
return context;
}

View File

@@ -0,0 +1,19 @@
import * as React from "react";
const MOBILE_BREAKPOINT = 768;
export function useIsMobile() {
const [isMobile, setIsMobile] = React.useState<boolean | undefined>(undefined);
React.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;
}

51
web/src/lib/pocketbase.ts Normal file
View File

@@ -0,0 +1,51 @@
/**
* PocketBase Client Configuration
*
* Singleton PocketBase client instance with automatic environment detection.
* Connects to local instance in development, production URL in production.
*/
import PocketBase from "pocketbase";
/**
* PocketBase URL configuration based on environment
* Development: Local PocketBase instance (http://127.0.0.1:8090)
* Production: Production PocketBase instance
*
* @returns PocketBase API base URL
*/
const getPocketBaseUrl = (): string => {
const isDev = import.meta.env.MODE === "development";
if (isDev) {
return "http://localhost:8090";
}
/* Production URL - configure via build process or use relative URL */
return import.meta.env.VITE_POCKETBASE_URL || "https://api.yourdomain.com";
};
/**
* Singleton PocketBase client instance
* Import this instance throughout the application for all PocketBase operations
*/
export const pb = new PocketBase(getPocketBaseUrl());
/**
* Disable auto-cancellation to prevent request interruptions
* By default, PocketBase cancels pending requests when new ones are made,
* which can cause issues with concurrent requests and real-time subscriptions
*/
pb.autoCancellation(false);
/**
* PocketBase collection names
* Centralized collection name constants to avoid typos and enable refactoring
*/
export const Collections = {
About: "about",
Companies: "companies",
Notices: "notices",
FloorPlans: "floor_plans",
Rooms: "rooms",
} as const;

65
web/src/lib/result.ts Normal file
View File

@@ -0,0 +1,65 @@
/**
* Result Utility
*
* Go-style error handling utilities for safer, more explicit error handling.
* Provides Result type and tryCatch wrappers to eliminate try-catch boilerplate.
*/
/**
* Go-style Result type for explicit error handling
* Returns [data, null] on success or [null, error] on failure
*
* @example
* const [data, error] = tryCatch(() => riskyOperation());
* if (error) {
* // Handle error
* return;
* }
* // Use data safely
*/
export type Result<T, E = Error> = [T, null] | [null, E];
/**
* Wraps a function in try-catch and returns Result tuple
* Similar to Go's (value, error) pattern
*
* @param fn Function to execute
* @returns Tuple of [result, null] or [null, error]
*/
export function tryCatch<T>(fn: () => T): Result<T> {
try {
const result = fn();
return [result, null];
} catch (error) {
return [null, error instanceof Error ? error : new Error(String(error))];
}
}
/**
* Async version of tryCatch for promises
*
* @param fn Async function to execute
* @returns Promise resolving to tuple of [result, null] or [null, error]
*/
export async function tryCatchAsync<T>(fn: () => Promise<T>): Promise<Result<T>> {
try {
const result = await fn();
return [result, null];
} catch (error) {
return [null, error instanceof Error ? error : new Error(String(error))];
}
}
/**
* Safely executes a void function, ignoring any errors
* Perfect for cleanup operations like unsubscribe where errors don't matter
*
* @param fn Function to execute safely
*/
export function safely(fn: () => void): void {
try {
fn();
} catch {
/* Ignore errors during cleanup */
}
}

73
web/src/lib/utils.ts Normal file
View File

@@ -0,0 +1,73 @@
/**
* Utility Functions
*
* Common helper functions for the application including:
* - UI styling utilities (shadcn)
* - Date formatting for notices
* - PocketBase file URL resolution
*/
/* Shadcn */
import { twMerge } from "tailwind-merge";
import { clsx, type ClassValue } from "clsx";
import type { RecordModel } from "pocketbase";
import { pb } from "./pocketbase";
/* Combines Tailwind classes with proper conflict resolution */
const cn = (...inputs: ClassValue[]) => twMerge(clsx(inputs));
export { cn };
/**
* Formats PocketBase created timestamp to human-readable Bosnian format
* Examples: "Danas, 9:30", "Juče, 15:15", "18. Okt, 10:45"
*
* @param dateStr ISO 8601 datetime string from PocketBase
* @returns Formatted date string in Bosnian
*/
export function formatPostedDate(dateStr: string): string {
const date = new Date(dateStr);
const now = new Date();
const diffMs = now.getTime() - date.getTime();
const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24));
const timeStr = date.toLocaleTimeString("bs-BA", {
hour: "2-digit",
minute: "2-digit",
});
if (diffDays === 0) {
return `Danas, ${timeStr}`;
}
if (diffDays === 1) {
return `Juče, ${timeStr}`;
}
const dateStr2 = date.toLocaleDateString("bs-BA", {
day: "numeric",
month: "short",
});
return `${dateStr2}, ${timeStr}`;
}
/**
* Checks if a notice has expired
*
* @param expiresAt ISO 8601 datetime string
* @returns true if the expiry date has passed, false otherwise
*/
export function isExpired(expiresAt: string): boolean {
return new Date() > new Date(expiresAt);
}
/**
* Generates PocketBase file URL for a record's file field
*
* @param record PocketBase record containing the file
* @param filename Name of the file field
* @returns Full URL to the file, or empty string if no file
*/
export function getFileUrl(record: RecordModel, filename: string): string {
return filename ? pb.files.getURL(record, filename) : "";
}

50
web/src/routes/about.tsx Normal file
View File

@@ -0,0 +1,50 @@
/**
* About Page
*
* Displays information about credits and sponsors for the facility.
* Content is editable by staff through PocketBase rich text editor.
* Updates appear in real-time across all kiosks.
*/
/* Context */
import { useKioskData } from "@/contexts/kiosk-data-context";
/* Constants */
import { LOADING_MESSAGES, ERROR_MESSAGES, EMPTY_MESSAGES } from "@/constants/ui-text";
export function About() {
const { about, loading, error } = useKioskData();
/* Loading state */
if (loading) {
return (
<main className="flex h-full items-center justify-center">
<p className="text-muted-foreground">{LOADING_MESSAGES.content}</p>
</main>
);
}
/* Error state */
if (error) {
return (
<main className="flex h-full items-center justify-center">
<p className="text-destructive">{ERROR_MESSAGES.generic}</p>
</main>
);
}
/* Empty state - no about content created yet */
if (!about) {
return (
<main className="flex h-full items-center justify-center">
<p className="text-muted-foreground">{EMPTY_MESSAGES.noContent}</p>
</main>
);
}
return (
<main className="flex h-full flex-col overflow-auto">
<div className="prose prose-lg max-w-none" dangerouslySetInnerHTML={{ __html: about.content }} />
</main>
);
}

View File

@@ -0,0 +1,99 @@
/* React hooks */
import { useMemo } from "react";
/* UI Components */
import { Separator } from "@/components/ui/separator";
/* Kiosk Components */
import { NoticeCard } from "@/components/kiosk/notice-card";
/* Types & Constants */
import { type Notice } from "@/types/notice";
/* Context */
import { useKioskData } from "@/contexts/kiosk-data-context";
/* Utility helpers */
import { isExpired } from "@/lib/utils";
/* Constants */
import { LOADING_MESSAGES, ERROR_MESSAGES, SECTION_HEADERS } from "@/constants/ui-text";
/**
* Bulletin Board Component
*
* Displays notices sorted by expiry status and post date.
* Active notices appear first, followed by expired ones with visual distinction.
* Uses real-time PocketBase subscriptions for instant updates across all kiosks.
*/
export function Bulletin() {
const { notices, loading, error } = useKioskData();
/**
* Sort and partition notices in a single pass
* Active notices first (newest to oldest), then expired notices (newest to oldest)
* Optimized to check expiry status only once per notice
*/
const { activeNotices, expiredNotices } = useMemo(() => {
const active: Notice[] = [];
const expired: Notice[] = [];
/* Partition notices by expiry status */
for (const notice of notices) {
if (isExpired(notice.expires_at)) {
expired.push(notice);
} else {
active.push(notice);
}
}
/* Sort each group by creation date (newest first) */
const sortByNewest = (a: Notice, b: Notice) => new Date(b.created).getTime() - new Date(a.created).getTime();
active.sort(sortByNewest);
expired.sort(sortByNewest);
return { activeNotices: active, expiredNotices: expired };
}, [notices]);
/* Loading state */
if (loading) {
return (
<main className="flex h-full items-center justify-center">
<p className="text-muted-foreground">{LOADING_MESSAGES.notices}</p>
</main>
);
}
/* Error state */
if (error) {
return (
<main className="flex h-full items-center justify-center">
<p className="text-destructive">{ERROR_MESSAGES.generic}</p>
</main>
);
}
return (
<main className="flex h-full flex-col gap-4">
{/* Active notices section */}
{activeNotices.map((notice) => (
<NoticeCard key={notice.id} notice={notice} />
))}
{/* Visual separator between active and expired notices */}
{expiredNotices.length > 0 && (
<div className="relative flex items-center py-2">
<Separator className="flex-1" />
<span className="text-muted-foreground px-4 text-sm">{SECTION_HEADERS.expiredNotices}</span>
<Separator className="flex-1" />
</div>
)}
{/* Expired notices section */}
{expiredNotices.map((notice) => (
<NoticeCard key={notice.id} notice={notice} />
))}
</main>
);
}

View File

@@ -0,0 +1,276 @@
/**
* Directory Page
*
* Interactive company directory for facility tenants and visitors.
* Features smart filtering with dynamically generated alphabet buttons,
* presence-based status filtering, and auto-clearing unavailable selections.
* Companies are sorted with present companies first for easy sign-out access.
*/
/* React hooks */
import { useState, useEffect, useMemo, useCallback } from "react";
/* UI Components */
import { Button } from "@/components/ui/button";
import { Separator } from "@/components/ui/separator";
/* Kiosk Components */
import { CompanyCard } from "@/components/kiosk/company-card";
/* Types & Constants */
import { type Company } from "@/types/company";
import type { Room, FloorPlanWithRooms } from "@/types/floor-plan";
/* Context */
import { useKioskData } from "@/contexts/kiosk-data-context";
/* Services */
import { updateCompanyStatus } from "@/services/companies";
/* Constants */
import { LOADING_MESSAGES, ERROR_MESSAGES, EMPTY_MESSAGES, SECTION_HEADERS, FILTER_LABELS } from "@/constants/ui-text";
/* Filter state options: all companies, present only, or absent only */
type StatusFilter = "all" | "present" | "absent";
/**
* Directory Component
*
* Manages filtering state and renders company directory with smart features:
* - Dynamic alphabet generation (only shows letters that exist in data)
* - Smart letter availability (disabled when no matches for current filter)
* - Auto-clearing selections when they become invalid
* - Sorted sections (present first for easy sign-out, then absent)
* - Real-time updates via PocketBase subscriptions
*/
export function Directory() {
const { companies, floorPlans, loading, error } = useKioskData();
const [selectedLetter, setSelectedLetter] = useState<string | null>(null);
const [statusFilter, setStatusFilter] = useState<StatusFilter>("all");
const [updatingCompanyId, setUpdatingCompanyId] = useState<string | null>(null);
/**
* Checks if a company matches the current status filter
* Extracted to avoid duplication - used in both availableLetters and filteredCompanies
*/
const matchesStatusFilter = useCallback(
(company: Company) => {
return (
statusFilter === "all" ||
(statusFilter === "present" && company.status === "present") ||
(statusFilter === "absent" && company.status === "absent")
);
},
[statusFilter],
);
/**
* Handles toggling company status between present and absent
* Updates via PocketBase API and real-time subscription auto-updates UI
*/
const handleStatusToggle = useCallback(async (company: Company): Promise<void> => {
const newStatus = company.status === "present" ? "absent" : "present";
setUpdatingCompanyId(company.id);
try {
await updateCompanyStatus(company.id, newStatus);
/* Real-time subscription will automatically update the UI */
} catch (error) {
console.error("Failed to update company status:", error);
/* TODO: Could add error toast notification here */
} finally {
setUpdatingCompanyId(null);
}
}, []);
/**
* Creates lookup map for company locations
* Prevents O(n*m) lookups when rendering company cards
*/
const companyLocationMap = useMemo(() => {
const map = new Map<string, { room: Room; floorPlan: FloorPlanWithRooms }>();
for (const floorPlan of floorPlans) {
for (const room of floorPlan.rooms) {
if (room.company) {
map.set(room.company, { room, floorPlan });
}
}
}
return map;
}, [floorPlans]);
/**
* Generates dynamic alphabet from actual company data
* Only displays letters that exist in the company list
*/
const alphabet = useMemo(() => {
const letters = new Set<string>();
companies.forEach((company) => {
const firstLetter = company.short_name.charAt(0).toUpperCase();
letters.add(firstLetter);
});
return Array.from(letters).sort();
}, [companies]);
/**
* Calculates which alphabet letters have companies for the current status filter
* Returns Set for O(1) lookup when rendering alphabet buttons
*/
const availableLetters = useMemo(() => {
const letters = new Set<string>();
companies.forEach((company) => {
if (matchesStatusFilter(company)) {
const firstLetter = company.short_name.charAt(0).toUpperCase();
letters.add(firstLetter);
}
});
return letters;
}, [companies, matchesStatusFilter]);
/**
* Auto-clear selected letter when it becomes unavailable
* Example: User has "M" selected, switches to "Prisutni" but no present
* companies start with "M" - auto-clears to show all present companies
*/
useEffect(() => {
if (selectedLetter && !availableLetters.has(selectedLetter)) {
setSelectedLetter(null);
}
}, [statusFilter, selectedLetter, availableLetters]);
/* Filter companies by both letter selection and status filter */
const filteredCompanies = useMemo(() => {
return companies.filter((company) => {
const matchesLetter = !selectedLetter || company.short_name.toUpperCase().startsWith(selectedLetter);
return matchesLetter && matchesStatusFilter(company);
});
}, [companies, selectedLetter, matchesStatusFilter]);
/* Separate and sort companies: present first (for easy sign-out), then absent */
const presentCompanies = filteredCompanies
.filter((c) => c.status === "present")
.sort((a, b) => a.short_name.localeCompare(b.short_name));
const absentCompanies = filteredCompanies
.filter((c) => c.status === "absent")
.sort((a, b) => a.short_name.localeCompare(b.short_name));
/* Loading state */
if (loading) {
return (
<main className="flex h-full items-center justify-center">
<p className="text-muted-foreground">{LOADING_MESSAGES.companies}</p>
</main>
);
}
/* Error state */
if (error) {
return (
<main className="flex h-full items-center justify-center">
<p className="text-destructive">{ERROR_MESSAGES.generic}</p>
</main>
);
}
return (
<main className="flex h-full flex-col gap-6">
{/* Filter controls: Status filter and dynamic alphabet navigation */}
<div className="flex items-center justify-center gap-8">
{/* Status filter: All, Present, or Absent companies */}
<div className="flex gap-2">
<Button
variant={statusFilter === "all" ? "default" : "outline"}
onClick={() => setStatusFilter("all")}
className="h-12"
>
{FILTER_LABELS.all}
</Button>
<Button
variant={statusFilter === "present" ? "default" : "outline"}
onClick={() => setStatusFilter("present")}
className="h-12"
>
{FILTER_LABELS.present}
</Button>
<Button
variant={statusFilter === "absent" ? "default" : "outline"}
onClick={() => setStatusFilter("absent")}
className="h-12"
>
{FILTER_LABELS.absent}
</Button>
</div>
{/* Dynamic alphabet: Only shows letters that exist in company data */}
<div className="flex gap-2">
{alphabet.map((letter) => {
const isAvailable = availableLetters.has(letter);
const isSelected = selectedLetter === letter;
return (
<Button
key={letter}
variant={isSelected ? "default" : "outline"}
disabled={!isAvailable}
onClick={() => isAvailable && setSelectedLetter(isSelected ? null : letter)}
className={`h-14 w-14 p-0 ${!isAvailable ? "cursor-not-allowed opacity-40" : ""}`}
>
{letter}
</Button>
);
})}
</div>
</div>
{/* Present companies grid (shown first for easy tenant sign-out) */}
<div className="grid grid-cols-3 gap-4">
{presentCompanies.map((company) => (
<CompanyCard
key={company.id}
company={company}
location={companyLocationMap.get(company.id) || null}
isUpdating={updatingCompanyId === company.id}
onStatusToggle={handleStatusToggle}
/>
))}
</div>
{/* Visual separator between present and absent sections */}
{absentCompanies.length > 0 && presentCompanies.length > 0 && (
<div className="relative flex items-center py-2">
<Separator className="flex-1" />
<span className="text-muted-foreground px-4 text-sm">{SECTION_HEADERS.absentCompanies}</span>
<Separator className="flex-1" />
</div>
)}
{/* Absent companies grid (shown below separator) */}
{absentCompanies.length > 0 && (
<div className="grid grid-cols-3 gap-4">
{absentCompanies.map((company) => (
<CompanyCard
key={company.id}
company={company}
location={companyLocationMap.get(company.id) || null}
isUpdating={updatingCompanyId === company.id}
onStatusToggle={handleStatusToggle}
/>
))}
</div>
)}
{/* Empty state: Shown when no companies match the filter criteria */}
{filteredCompanies.length === 0 && (
<div className="flex flex-1 items-center justify-center">
<p className="text-muted-foreground">{EMPTY_MESSAGES.noCompanies}</p>
</div>
)}
</main>
);
}

View File

@@ -0,0 +1,312 @@
/**
* Navigation Page
*
* Interactive building navigation with full-screen SVG floor plans.
* Features compact inverted L-shape grid controls with annex filtering.
*
* Key Features:
* - Inverted L-shape grid: [Floor0] [A] [B] on top, remaining floors vertically
* - Annex buttons act as filters - clicking switches all floors to that annex
* - Dynamic floor buttons - only shows floors available for selected annex
* - Auto-selection when switching to annex without current floor
* - Deep linking support for wayfinding (?floor=...&annex=...&room=...)
* - Company logos positioned on floor plans via coordinates
* - Coordinate helper (hold Shift) for easy admin setup
*/
/* React hooks */
import { useState, useEffect, useMemo } from "react";
import { useLocation } from "wouter";
/* UI Components */
import { Button } from "@/components/ui/button";
/* Kiosk Components */
import { RoomInfoPanel } from "@/components/kiosk/room-info-panel";
import { FloorPlanMarkers } from "@/components/kiosk/floor-plan-markers";
/* Types */
import type { Room } from "@/types/floor-plan";
/* Context */
import { useKioskData } from "@/contexts/kiosk-data-context";
/* Utility helpers */
import { getFileUrl } from "@/lib/utils";
/* Constants */
import { LOADING_MESSAGES, ERROR_MESSAGES, EMPTY_MESSAGES } from "@/constants/ui-text";
/**
* Navigation Component
*
* Displays interactive floor plans with inverted L-shape grid navigation.
* Annexes filter which floor plans are shown, floors select the specific plan.
*
* Navigation Layout:
* - Top row: First floor button + annex filter buttons (A, B, C...)
* - Left column: Remaining floor buttons (dynamically filtered by selected annex)
* - Clicking annex button switches all floors to that annex
* - Clicking floor button selects that floor with current annex
*
* Features:
* - Auto-selection when current floor doesn't exist in new annex
* - Deep linking from Directory for wayfinding
* - Company logos positioned on floor plans via coordinates
* - Coordinate helper for admin setup (hold Shift)
* - Real-time updates via PocketBase subscriptions
*/
export function Navigation() {
const { floorPlans, loading, error } = useKioskData();
const [location] = useLocation();
/* Selected floor and annex state for inverted L-shape navigation */
const [selectedFloor, setSelectedFloor] = useState<string>("");
const [selectedAnnex, setSelectedAnnex] = useState<string>("");
const [selectedRoom, setSelectedRoom] = useState<Room | null>(null);
/* Coordinate helper state */
const [isShiftPressed, setIsShiftPressed] = useState(false);
const [mousePosition, setMousePosition] = useState<{ x: number; y: number } | null>(null);
/* Extract unique annexes from all floor plans */
const annexes = useMemo(() => Array.from(new Set(floorPlans.map((fp) => fp.annex))).sort(), [floorPlans]);
/* Extract unique floors for the currently selected annex only */
/* Preserve floor order based on first occurrence's sort_order */
const floors = useMemo(() => {
return Array.from(
new Map(floorPlans.filter((fp) => fp.annex === selectedAnnex).map((fp) => [fp.floor, fp.sort_order])).entries(),
)
.sort(([, a], [, b]) => a - b)
.map(([floor]) => floor);
}, [floorPlans, selectedAnnex]);
/* Initialize with first floor plan when data loads */
useEffect(() => {
if (floorPlans.length > 0 && !selectedFloor && !selectedAnnex) {
setSelectedFloor(floorPlans[0].floor);
setSelectedAnnex(floorPlans[0].annex);
}
}, [floorPlans, selectedFloor, selectedAnnex]);
/* Get current floor plan by selected floor and annex */
const currentFloorPlan = floorPlans.find((fp) => fp.floor === selectedFloor && fp.annex === selectedAnnex);
/* Auto-select valid floor when switching to annex with different available floors */
useEffect(() => {
if (!currentFloorPlan && selectedAnnex && floorPlans.length > 0) {
/* Find first floor plan for the selected annex */
const firstForAnnex = floorPlans.find((fp) => fp.annex === selectedAnnex);
if (firstForAnnex) {
setSelectedFloor(firstForAnnex.floor);
}
}
}, [currentFloorPlan, selectedAnnex, floorPlans]);
/**
* Handle deep linking from URL parameters
* Enables wayfinding: Click company in Directory → auto-navigate to their
* floor plan with room highlighted. Format: ?floor=Suteren&annex=A&room=S-01A
*/
useEffect(() => {
const params = new URLSearchParams(window.location.hash.split("?")[1]);
const floor = params.get("floor");
const annex = params.get("annex");
const roomId = params.get("room");
/* Set floor and annex from URL parameters */
if (floor && annex) {
const floorPlan = floorPlans.find((fp) => fp.floor === floor && fp.annex === annex);
if (floorPlan) {
setSelectedFloor(floor);
setSelectedAnnex(annex);
/* Find and select room if specified */
if (roomId) {
const room = floorPlan.rooms.find((r) => r.id === roomId);
if (room) {
setSelectedRoom(room);
}
}
}
}
}, [location, floorPlans]);
/**
* Keyboard listener for coordinate helper (Shift key toggle)
* Hold Shift to show x, y coordinates on floor plan
*/
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === "Shift") {
setIsShiftPressed(true);
}
};
const handleKeyUp = (e: KeyboardEvent) => {
if (e.key === "Shift") {
setIsShiftPressed(false);
}
};
window.addEventListener("keydown", handleKeyDown);
window.addEventListener("keyup", handleKeyUp);
return () => {
window.removeEventListener("keydown", handleKeyDown);
window.removeEventListener("keyup", handleKeyUp);
};
}, []);
/**
* Handle mouse movement over floor plan to calculate coordinates
* Converts pixel position to percentage (0-100) for easy PocketBase entry
*/
const handleMouseMove = (e: React.MouseEvent<HTMLDivElement>) => {
if (!isShiftPressed) return;
const rect = e.currentTarget.getBoundingClientRect();
const x = ((e.clientX - rect.left) / rect.width) * 100;
const y = ((e.clientY - rect.top) / rect.height) * 100;
setMousePosition({ x, y });
};
/**
* Clear mouse position when mouse leaves floor plan container
*/
const handleMouseLeave = () => {
setMousePosition(null);
};
/* Loading state */
if (loading) {
return (
<main className="flex h-full items-center justify-center">
<p className="text-muted-foreground">{LOADING_MESSAGES.floorPlans}</p>
</main>
);
}
/* Error state */
if (error) {
return (
<main className="flex h-full items-center justify-center">
<p className="text-destructive">{ERROR_MESSAGES.generic}</p>
</main>
);
}
return (
<main className="flex h-full flex-col">
{/* Floor plan display with floating controls overlay */}
{currentFloorPlan ? (
<div className="relative flex h-full flex-col">
{/**
* Floating Control Overlay - Compact inverted L-shape grid
* Top row: Floor0 + Annex filters [0] [A] [B]
* Left column: Remaining floors [1] [2] [3]
* Annexes act as filters - clicking changes which annex to show for all floors
*/}
<div className="absolute top-4 left-4 z-10 flex flex-col gap-2">
{/* Top row: First floor button + Annex filter buttons */}
<div className="flex gap-2">
{/* First floor button (corner position) */}
{floors.length > 0 && (
<Button
variant={selectedFloor === floors[0] ? "default" : "outline"}
onClick={() => {
setSelectedFloor(floors[0]);
setSelectedRoom(null);
}}
className="h-14 w-14 p-0 font-bold shadow-md"
>
{floors[0].charAt(0)}
</Button>
)}
{/* Annex filter buttons horizontally */}
{annexes.map((annex) => (
<Button
key={annex}
variant={selectedAnnex === annex ? "default" : "outline"}
onClick={() => {
setSelectedAnnex(annex);
setSelectedRoom(null);
}}
className="h-14 w-14 p-0 font-bold shadow-md"
>
{annex}
</Button>
))}
</div>
{/* Remaining floor buttons vertically (Floor 1, 2, 3...) */}
{floors.slice(1).map((floor) => (
<Button
key={floor}
variant={selectedFloor === floor ? "default" : "outline"}
onClick={() => {
setSelectedFloor(floor);
setSelectedRoom(null);
}}
className="h-14 w-14 p-0 font-bold shadow-md"
>
{floor.charAt(0)}
</Button>
))}
</div>
{/**
* SVG Floor Plan Container
* Uses h-0 + flex-1 trick to force viewport fitting without scrolling
* Image scales to fit available space with object-contain
* Coordinate helper shows position when Shift is held
*/}
<div
className="relative flex h-0 flex-1 items-center justify-center overflow-hidden rounded-lg border bg-white"
onMouseMove={handleMouseMove}
onMouseLeave={handleMouseLeave}
>
<img
src={getFileUrl(currentFloorPlan, currentFloorPlan.svg_file)}
alt={`${currentFloorPlan.floor} - ${currentFloorPlan.annex}`}
className="max-h-full max-w-full object-contain"
onError={(e) => {
console.error("Failed to load floor plan:", getFileUrl(currentFloorPlan, currentFloorPlan.svg_file));
e.currentTarget.style.display = "none";
}}
/>
{/* Company logos positioned on floor plan */}
<FloorPlanMarkers rooms={currentFloorPlan.rooms} />
{/* Coordinate helper - Shows x,y when Shift is held */}
{isShiftPressed && mousePosition && (
<div
className="pointer-events-none absolute rounded bg-black/80 px-3 py-2 font-mono text-sm text-white shadow-lg"
style={{
left: mousePosition.x + "%",
top: mousePosition.y + "%",
transform: "translate(10px, 10px)",
}}
>
x: {mousePosition.x.toFixed(1)}%, y: {mousePosition.y.toFixed(1)}%
</div>
)}
</div>
{/* Floating room info panel - Appears at bottom-center when room selected */}
{selectedRoom && <RoomInfoPanel room={selectedRoom} onClose={() => setSelectedRoom(null)} />}
</div>
) : (
/* Empty state: Shown when no floor plan exists for current selection */
<div className="bg-muted/30 flex flex-1 items-center justify-center rounded-lg border">
<p className="text-muted-foreground">{EMPTY_MESSAGES.noFloorPlan}</p>
</div>
)}
</main>
);
}

40
web/src/routes/routes.tsx Normal file
View File

@@ -0,0 +1,40 @@
/**
* Application Routes Configuration
*
* Defines the main routing structure for the kiosk application using Wouter.
* Hash-based routing is used for compatibility with static hosting environments.
*/
/* Routing library */
import { Router, Switch, Route } from "wouter";
import { useHashLocation } from "wouter/use-hash-location";
/* Context providers */
import { KioskDataProvider } from "@/contexts/kiosk-data-context";
/* Layout wrapper */
import { Layout } from "@/components/kiosk/layout";
/* Page components */
import { Bulletin } from "@/routes/bulletin";
import { Directory } from "@/routes/directory";
import { Navigation } from "@/routes/navigation";
import { About } from "@/routes/about";
const Routes = () => (
<KioskDataProvider>
<Router hook={useHashLocation}>
<Layout>
<Switch>
<Route path="/" component={Bulletin} />
<Route path="/directory" component={Directory} />
<Route path="/navigation" component={Navigation} />
<Route path="/about" component={About} />
<Route component={() => <>Not Found</>} />
</Switch>
</Layout>
</Router>
</KioskDataProvider>
);
export default Routes;

42
web/src/services/about.ts Normal file
View File

@@ -0,0 +1,42 @@
/**
* About Service
*
* Handles data operations for the about page content including fetching
* and real-time subscriptions for staff edits.
*
* Returns data directly from PocketBase with no transformations.
* Uses singleton pattern - only one about page record exists.
*/
import { pb, Collections } from "@/lib/pocketbase";
import type { About } from "@/types/about";
import { safely } from "@/lib/result";
/**
* Fetches the about page content from PocketBase
* Returns the first (and only) about record
*
* @returns About page content or null if not created yet
*/
export async function getAbout(): Promise<About | null> {
const records = await pb.collection(Collections.About).getFullList<About>();
return records.length > 0 ? records[0] : null;
}
/**
* Subscribes to real-time about page updates
* Callback is invoked whenever the about content is updated by staff
*
* @param callback Function to call when about content changes
* @returns Unsubscribe function to clean up the subscription
*/
export function subscribeToAbout(callback: (about: About | null) => void): () => void {
pb.collection(Collections.About).subscribe<About>("*", async () => {
const about = await getAbout();
callback(about);
});
return () => {
safely(() => pb.collection(Collections.About).unsubscribe("*"));
};
}

View File

@@ -0,0 +1,53 @@
/**
* Companies Service
*
* Handles all data operations for companies including fetching,
* updating presence status, and real-time subscriptions.
*
* Returns data directly from PocketBase with no transformations.
* File URLs are resolved at display time using getFileUrl() helper.
*/
import { pb, Collections } from "@/lib/pocketbase";
import type { Company } from "@/types/company";
import { safely } from "@/lib/result";
/**
* Fetches all companies from PocketBase
* Returns companies sorted alphabetically by short name
*
* @returns Array of all company records sorted by name
*/
export async function getCompanies(): Promise<Company[]> {
return await pb.collection(Collections.Companies).getFullList<Company>({
sort: "short_name",
});
}
/**
* Updates a company's presence status (present/absent)
* Used when tenants mark themselves as present or absent at the kiosk
*/
export async function updateCompanyStatus(companyId: string, status: "present" | "absent"): Promise<Company> {
return await pb.collection(Collections.Companies).update<Company>(companyId, {
status,
});
}
/**
* Subscribes to real-time company updates
* Callback is invoked whenever any company record is created, updated, or deleted
*
* @param callback Function to call when companies change
* @returns Unsubscribe function to clean up the subscription
*/
export function subscribeToCompanies(callback: (companies: Company[]) => void): () => void {
pb.collection(Collections.Companies).subscribe<Company>("*", async () => {
const companies = await getCompanies();
callback(companies);
});
return () => {
safely(() => pb.collection(Collections.Companies).unsubscribe("*"));
};
}

View File

@@ -0,0 +1,65 @@
/**
* Floor Plans Service
*
* Handles all data operations for floor plans including fetching
* floor plan data with associated rooms and real-time subscriptions.
*
* Returns data directly from PocketBase with expand relations.
* File URLs are resolved at display time using getFileUrl() helper.
*/
import { pb, Collections } from "@/lib/pocketbase";
import type { FloorPlan, FloorPlanWithRooms, Room } from "@/types/floor-plan";
import { safely } from "@/lib/result";
/**
* Fetches all floor plans with their associated rooms
* Expands room -> company relations for complete data
* Returns floor plans sorted by sort_order
*
* @returns Array of floor plans with nested rooms and company data
*/
export async function getFloorPlans(): Promise<FloorPlanWithRooms[]> {
/* Fetch all floor plans */
const floorPlans = await pb.collection(Collections.FloorPlans).getFullList<FloorPlan>({
sort: "sort_order",
});
/* Fetch all rooms with company relations expanded */
const rooms = await pb.collection(Collections.Rooms).getFullList<Room>({
expand: "company",
});
/* Combine floor plans with their rooms */
return floorPlans.map((floorPlan) => ({
...floorPlan,
rooms: rooms.filter((room) => room.floor_plan === floorPlan.id),
}));
}
/**
* Subscribes to real-time floor plan and room updates
* Callback is invoked whenever floor plans or rooms change
* Monitors both collections to ensure complete updates
*
* @param callback Function to call when floor plans change
* @returns Unsubscribe function to clean up all subscriptions
*/
export function subscribeToFloorPlans(callback: (floorPlans: FloorPlanWithRooms[]) => void): () => void {
pb.collection(Collections.FloorPlans).subscribe<FloorPlan>("*", async () => {
const floorPlans = await getFloorPlans();
callback(floorPlans);
});
pb.collection(Collections.Rooms).subscribe<Room>("*", async () => {
const floorPlans = await getFloorPlans();
callback(floorPlans);
});
return () => {
safely(() => {
pb.collection(Collections.FloorPlans).unsubscribe("*");
pb.collection(Collections.Rooms).unsubscribe("*");
});
};
}

View File

@@ -0,0 +1,44 @@
/**
* Notices Service
*
* Handles all data operations for notices including fetching
* and real-time subscriptions for bulletin board display.
*
* Returns data directly from PocketBase with no transformations.
* Uses created field from RecordModel for posted date.
*/
import { pb, Collections } from "@/lib/pocketbase";
import type { Notice } from "@/types/notice";
import { safely } from "@/lib/result";
/**
* Fetches all notices from PocketBase
* Returns notices sorted by created date (newest first)
* Includes both active and expired notices for display
*
* @returns Array of all notice records sorted by creation date
*/
export async function getNotices(): Promise<Notice[]> {
return await pb.collection(Collections.Notices).getFullList<Notice>({
sort: "-created",
});
}
/**
* Subscribes to real-time notice updates
* Callback is invoked whenever any notice is created, updated, or deleted
*
* @param callback Function to call when notices change
* @returns Unsubscribe function to clean up the subscription
*/
export function subscribeToNotices(callback: (notices: Notice[]) => void): () => void {
pb.collection(Collections.Notices).subscribe<Notice>("*", async () => {
const notices = await getNotices();
callback(notices);
});
return () => {
safely(() => pb.collection(Collections.Notices).unsubscribe("*"));
};
}

213
web/src/styles/tailwind.css Normal file
View File

@@ -0,0 +1,213 @@
@import "tailwindcss";
@import "tw-animate-css";
@plugin "@tailwindcss/typography";
@custom-variant dark (&:is(.dark *));
@theme inline {
--radius-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) + 4px);
--color-background: var(--background);
--color-foreground: var(--foreground);
--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-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);
}
:root {
--background: oklch(0.9842 0.0034 247.8575);
--foreground: oklch(0.2795 0.0368 260.031);
--card: oklch(1 0 0);
--card-foreground: oklch(0.2795 0.0368 260.031);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.2795 0.0368 260.031);
--primary: oklch(0.5854 0.2041 277.1173);
--primary-foreground: oklch(1 0 0);
--secondary: oklch(0.9276 0.0058 264.5313);
--secondary-foreground: oklch(0.3729 0.0306 259.7328);
--muted: oklch(0.967 0.0029 264.5419);
--muted-foreground: oklch(0.551 0.0234 264.3637);
--accent: oklch(0.9299 0.0334 272.7879);
--accent-foreground: oklch(0.3729 0.0306 259.7328);
--destructive: oklch(0.6368 0.2078 25.3313);
--destructive-foreground: oklch(1 0 0);
--border: oklch(0.8717 0.0093 258.3382);
--input: oklch(0.8717 0.0093 258.3382);
--ring: oklch(0.5854 0.2041 277.1173);
--chart-1: oklch(0.5854 0.2041 277.1173);
--chart-2: oklch(0.5106 0.2301 276.9656);
--chart-3: oklch(0.4568 0.2146 277.0229);
--chart-4: oklch(0.3984 0.1773 277.3662);
--chart-5: oklch(0.3588 0.1354 278.6973);
--sidebar: oklch(0.967 0.0029 264.5419);
--sidebar-foreground: oklch(0.2795 0.0368 260.031);
--sidebar-primary: oklch(0.5854 0.2041 277.1173);
--sidebar-primary-foreground: oklch(1 0 0);
--sidebar-accent: oklch(0.9299 0.0334 272.7879);
--sidebar-accent-foreground: oklch(0.3729 0.0306 259.7328);
--sidebar-border: oklch(0.8717 0.0093 258.3382);
--sidebar-ring: oklch(0.5854 0.2041 277.1173);
--font-sans: Inter, sans-serif;
--font-serif: Merriweather, serif;
--font-mono: JetBrains Mono, monospace;
--radius: 0rem;
--shadow-x: 0px;
--shadow-y: 4px;
--shadow-blur: 8px;
--shadow-spread: -1px;
--shadow-opacity: 0.1;
--shadow-color: hsl(0 0% 0%);
--shadow-2xs: 0px 4px 8px -1px hsl(0 0% 0% / 0.05);
--shadow-xs: 0px 4px 8px -1px hsl(0 0% 0% / 0.05);
--shadow-sm: 0px 4px 8px -1px hsl(0 0% 0% / 0.1), 0px 1px 2px -2px hsl(0 0% 0% / 0.1);
--shadow: 0px 4px 8px -1px hsl(0 0% 0% / 0.1), 0px 1px 2px -2px hsl(0 0% 0% / 0.1);
--shadow-md: 0px 4px 8px -1px hsl(0 0% 0% / 0.1), 0px 2px 4px -2px hsl(0 0% 0% / 0.1);
--shadow-lg: 0px 4px 8px -1px hsl(0 0% 0% / 0.1), 0px 4px 6px -2px hsl(0 0% 0% / 0.1);
--shadow-xl: 0px 4px 8px -1px hsl(0 0% 0% / 0.1), 0px 8px 10px -2px hsl(0 0% 0% / 0.1);
--shadow-2xl: 0px 4px 8px -1px hsl(0 0% 0% / 0.25);
--tracking-normal: 0em;
--spacing: 0.25rem;
}
.dark {
--background: oklch(0.2077 0.0398 265.7549);
--foreground: oklch(0.9288 0.0126 255.5078);
--card: oklch(0.2795 0.0368 260.031);
--card-foreground: oklch(0.9288 0.0126 255.5078);
--popover: oklch(0.2795 0.0368 260.031);
--popover-foreground: oklch(0.9288 0.0126 255.5078);
--primary: oklch(0.6801 0.1583 276.9349);
--primary-foreground: oklch(0.2077 0.0398 265.7549);
--secondary: oklch(0.3351 0.0331 260.912);
--secondary-foreground: oklch(0.8717 0.0093 258.3382);
--muted: oklch(0.2427 0.0381 259.9437);
--muted-foreground: oklch(0.7137 0.0192 261.3246);
--accent: oklch(0.3729 0.0306 259.7328);
--accent-foreground: oklch(0.8717 0.0093 258.3382);
--destructive: oklch(0.6368 0.2078 25.3313);
--destructive-foreground: oklch(0.2077 0.0398 265.7549);
--border: oklch(0.4461 0.0263 256.8018);
--input: oklch(0.4461 0.0263 256.8018);
--ring: oklch(0.6801 0.1583 276.9349);
--chart-1: oklch(0.6801 0.1583 276.9349);
--chart-2: oklch(0.5854 0.2041 277.1173);
--chart-3: oklch(0.5106 0.2301 276.9656);
--chart-4: oklch(0.4568 0.2146 277.0229);
--chart-5: oklch(0.3984 0.1773 277.3662);
--sidebar: oklch(0.2795 0.0368 260.031);
--sidebar-foreground: oklch(0.9288 0.0126 255.5078);
--sidebar-primary: oklch(0.6801 0.1583 276.9349);
--sidebar-primary-foreground: oklch(0.2077 0.0398 265.7549);
--sidebar-accent: oklch(0.3729 0.0306 259.7328);
--sidebar-accent-foreground: oklch(0.8717 0.0093 258.3382);
--sidebar-border: oklch(0.4461 0.0263 256.8018);
--sidebar-ring: oklch(0.6801 0.1583 276.9349);
--font-sans: Inter, sans-serif;
--font-serif: Merriweather, serif;
--font-mono: JetBrains Mono, monospace;
--radius: 0rem;
--shadow-x: 0px;
--shadow-y: 4px;
--shadow-blur: 8px;
--shadow-spread: -1px;
--shadow-opacity: 0.1;
--shadow-color: hsl(0 0% 0%);
--shadow-2xs: 0px 4px 8px -1px hsl(0 0% 0% / 0.05);
--shadow-xs: 0px 4px 8px -1px hsl(0 0% 0% / 0.05);
--shadow-sm: 0px 4px 8px -1px hsl(0 0% 0% / 0.1), 0px 1px 2px -2px hsl(0 0% 0% / 0.1);
--shadow: 0px 4px 8px -1px hsl(0 0% 0% / 0.1), 0px 1px 2px -2px hsl(0 0% 0% / 0.1);
--shadow-md: 0px 4px 8px -1px hsl(0 0% 0% / 0.1), 0px 2px 4px -2px hsl(0 0% 0% / 0.1);
--shadow-lg: 0px 4px 8px -1px hsl(0 0% 0% / 0.1), 0px 4px 6px -2px hsl(0 0% 0% / 0.1);
--shadow-xl: 0px 4px 8px -1px hsl(0 0% 0% / 0.1), 0px 8px 10px -2px hsl(0 0% 0% / 0.1);
--shadow-2xl: 0px 4px 8px -1px hsl(0 0% 0% / 0.25);
}
@theme inline {
--color-background: var(--background);
--color-foreground: var(--foreground);
--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);
--font-sans: var(--font-sans);
--font-mono: var(--font-mono);
--font-serif: var(--font-serif);
--radius-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) + 4px);
--shadow-2xs: var(--shadow-2xs);
--shadow-xs: var(--shadow-xs);
--shadow-sm: var(--shadow-sm);
--shadow: var(--shadow);
--shadow-md: var(--shadow-md);
--shadow-lg: var(--shadow-lg);
--shadow-xl: var(--shadow-xl);
--shadow-2xl: var(--shadow-2xl);
}
@layer base {
* {
@apply border-border outline-ring/50;
}
body {
@apply bg-background text-foreground;
}
}

19
web/src/types/about.ts Normal file
View File

@@ -0,0 +1,19 @@
/**
* About type definition
*
* Contains the about page content structure.
* Uses PocketBase RecordModel for automatic id, created, updated fields.
*/
import type { RecordModel } from "pocketbase";
/**
* About page content from PocketBase
*
* Extends RecordModel to get id, created, updated for free.
* Content field contains HTML from PocketBase rich text editor.
* Singleton pattern - only one about record exists.
*/
export interface About extends RecordModel {
content: string /* HTML content from rich text editor */;
}

43
web/src/types/company.ts Normal file
View File

@@ -0,0 +1,43 @@
/**
* Company type definitions and configuration
*
* Contains the core company data structure and associated styling constants
* for presence-based visual representation in the directory listing.
*
* Uses PocketBase RecordModel for automatic id, created, updated fields.
* File URLs are resolved at display time using getFileUrl() helper.
*/
import type { RecordModel } from "pocketbase";
/**
* Company from PocketBase
*
* Extends RecordModel to get id, created, updated for free.
* All fields use snake_case to match database schema directly.
* Logo field contains filename - use getFileUrl() to resolve URL.
*/
export interface Company extends RecordModel {
logo: string /* PocketBase file field name */;
short_name: string /* Display name (e.g., "TechCorp") */;
full_name: string /* Full legal name (e.g., "TechCorp International d.o.o.") */;
status: "present" | "absent";
}
/* Badge variant mapping for each status */
export const statusVariants = {
present: "default",
absent: "secondary",
} as const;
/* Border color classes for each status */
export const statusBorderColors = {
present: "border-primary",
absent: "border-border",
} as const;
/* Localized labels (Bosnian) for each status */
export const statusLabels = {
present: "Prisutne",
absent: "Odsutne",
} as const;

View File

@@ -0,0 +1,53 @@
/**
* Floor Plan type definitions and configuration
*
* Contains data structures for interactive building navigation,
* including floor plans, rooms, and their associated metadata.
*
* Uses PocketBase RecordModel for automatic id, created, updated fields.
* File URLs are resolved at display time using getFileUrl() helper.
*/
import type { RecordModel } from "pocketbase";
import type { Company } from "./company";
/**
* Floor Plan from PocketBase
*
* Extends RecordModel to get id, created, updated for free.
* All fields use snake_case to match database schema directly.
* SVG file field contains filename - use getFileUrl() to resolve URL.
*/
export interface FloorPlan extends RecordModel {
floor: string /* Floor level name (e.g., "Suteren", "Prizemlje") */;
annex: string /* Building annex name (e.g., "A", "B") */;
svg_file: string /* PocketBase file field name */;
sort_order: number /* Display order (lower = shown first) */;
}
/**
* Room from PocketBase
*
* Extends RecordModel to get id, created, updated for free.
* All fields use snake_case to match database schema directly.
* Company relation is expanded via PocketBase expand parameter.
*/
export interface Room extends RecordModel {
name: string /* Room identifier (e.g., "2-05A", "S-01A") */;
type?: string /* Room type (e.g., "Kancelarija", "WC", "Kuhinja") */;
floor_plan: string /* Relation to floor_plan */;
company?: string /* Relation to company (optional) */;
map_x?: number /* Horizontal position on floor plan as percentage (0-100) */;
map_y?: number /* Vertical position on floor plan as percentage (0-100) */;
expand?: {
company?: Company /* Expanded company relation */;
};
}
/**
* Floor Plan with associated rooms
* Combines floor plan metadata with room data for navigation
*/
export interface FloorPlanWithRooms extends FloorPlan {
rooms: Room[];
}

46
web/src/types/notice.ts Normal file
View File

@@ -0,0 +1,46 @@
/**
* Notice type definitions and configuration
*
* Contains the core notice data structure and associated styling constants
* for priority-based visual representation in the kiosk bulletin board.
*
* Uses PocketBase RecordModel for automatic id, created, updated fields.
* Date formatting is handled by utility functions in lib/utils.ts.
*/
import type { RecordModel } from "pocketbase";
/**
* Notice from PocketBase
*
* Extends RecordModel to get id, created, updated for free.
* Uses created field for posted date (no manual posted_at needed).
* All fields use snake_case to match database schema directly.
*/
export interface Notice extends RecordModel {
title: string;
message: string;
expires_at: string /* ISO 8601 datetime string */;
priority: "urgent" | "normal" | "info";
}
/* Badge variant mapping for each priority level */
export const priorityVariants = {
urgent: "destructive",
normal: "secondary",
info: "default",
} as const;
/* Border color classes for each priority level */
export const priorityBorderColors = {
urgent: "border-destructive",
normal: "border-border",
info: "border-primary",
} as const;
/* Localized labels (Bosnian) for each priority level */
export const priorityLabels = {
urgent: "Hitno",
normal: "Opšte",
info: "Informativno",
} as const;

15
web/src/vite-root.tsx Normal file
View File

@@ -0,0 +1,15 @@
/* Styles */
import "@/styles/tailwind.css";
/* React-related */
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
/* Route-related */
import Routes from "@/routes/routes";
createRoot(document.getElementById("root")!).render(
<StrictMode>
<Routes />
</StrictMode>,
);

32
web/tsconfig.app.json Normal file
View File

@@ -0,0 +1,32 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"target": "ES2022",
"useDefineForClassFields": true,
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"module": "ESNext",
"types": ["vite/client"],
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true,
/* Shadcn */
"baseUrl": ".",
"paths": { "@/*": ["./src/*"] }
},
"include": ["src"]
}

10
web/tsconfig.json Normal file
View File

@@ -0,0 +1,10 @@
{
"files": [],
"references": [{ "path": "./tsconfig.app.json" }, { "path": "./tsconfig.node.json" }],
/* Shadcn */
"compilerOptions": {
"baseUrl": ".",
"paths": { "@/*": ["./src/*"] }
}
}

26
web/tsconfig.node.json Normal file
View File

@@ -0,0 +1,26 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"target": "ES2023",
"lib": ["ES2023"],
"module": "ESNext",
"types": [],
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["vite.config.ts"]
}

14
web/vite.config.ts Normal file
View File

@@ -0,0 +1,14 @@
/* Vite config */
import path from "path";
import { defineConfig } from "vite";
/* Vite plugins */
import react from "@vitejs/plugin-react";
import tailwindcss from "@tailwindcss/vite";
export default defineConfig({
plugins: [react(), tailwindcss()],
/* Shadcn */
resolve: { alias: { "@": path.resolve(__dirname, "./src") } },
});