Version 1.0 squash

This commit is contained in:
Kenan Alić
2025-10-21 18:45:02 +02:00
parent 4d52f14287
commit 6fdaa16610
30 changed files with 2534 additions and 15 deletions

View File

@@ -1,14 +0,0 @@
/* Wouter routing library */
import { Router, Switch, Route } from "wouter";
import { useHashLocation } from "wouter/use-hash-location";
const Routes = () => (
<Router hook={useHashLocation}>
<Switch>
<Route path="/" component={() => <>Home</>} />
<Route component={() => <>NotFound</>} />
</Switch>
</Router>
);
export default Routes;

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;