Version 1.0 squash
This commit is contained in:
@@ -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
50
web/src/routes/about.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
99
web/src/routes/bulletin.tsx
Normal file
99
web/src/routes/bulletin.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
276
web/src/routes/directory.tsx
Normal file
276
web/src/routes/directory.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
312
web/src/routes/navigation.tsx
Normal file
312
web/src/routes/navigation.tsx
Normal 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
40
web/src/routes/routes.tsx
Normal 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;
|
||||
Reference in New Issue
Block a user