feat: migrate to Next.js for Mini App and enhance project structure
- Replaced the previous webapp with a new Mini App built using Next.js, improving performance and maintainability. - Updated the `.gitignore` to exclude Next.js build artifacts and node modules. - Revised documentation in `AGENTS.md`, `README.md`, and `architecture.md` to reflect the new Mini App structure and technology stack. - Enhanced Dockerfile to support the new build process for the Next.js application. - Updated CI workflow to build and test the Next.js application. - Added new configuration options for the Mini App, including `MINI_APP_SHORT_NAME` for improved deep linking. - Refactored frontend testing setup to accommodate the new structure and testing framework. - Removed legacy webapp files and dependencies to streamline the project.
This commit is contained in:
188
webapp-next/src/components/duty/DutyTimelineCard.tsx
Normal file
188
webapp-next/src/components/duty/DutyTimelineCard.tsx
Normal file
@@ -0,0 +1,188 @@
|
||||
/**
|
||||
* Timeline duty card: front = duty info + flip button; back = name + contacts + back button.
|
||||
* Flip card only when duty has phone or username. Ported from webapp/js/dutyList.js dutyTimelineCardHtml.
|
||||
*/
|
||||
|
||||
"use client";
|
||||
|
||||
import { useState, useMemo, useRef } from "react";
|
||||
import { useTranslation } from "@/i18n/use-translation";
|
||||
import {
|
||||
localDateString,
|
||||
dateKeyToDDMM,
|
||||
formatHHMM,
|
||||
} from "@/lib/date-utils";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { ContactLinks } from "@/components/contact/ContactLinks";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
TooltipProvider,
|
||||
} from "@/components/ui/tooltip";
|
||||
import type { DutyWithUser } from "@/types";
|
||||
import { Phone, ArrowLeft } from "lucide-react";
|
||||
|
||||
export interface DutyTimelineCardProps {
|
||||
duty: DutyWithUser;
|
||||
isCurrent: boolean;
|
||||
}
|
||||
|
||||
function buildTimeStr(duty: DutyWithUser): string {
|
||||
const startLocal = localDateString(new Date(duty.start_at));
|
||||
const endLocal = localDateString(new Date(duty.end_at));
|
||||
const startDDMM = dateKeyToDDMM(startLocal);
|
||||
const endDDMM = dateKeyToDDMM(endLocal);
|
||||
const startTime = formatHHMM(duty.start_at);
|
||||
const endTime = formatHHMM(duty.end_at);
|
||||
if (startLocal === endLocal) {
|
||||
return `${startDDMM}, ${startTime} – ${endTime}`;
|
||||
}
|
||||
return `${startDDMM} ${startTime} – ${endDDMM} ${endTime}`;
|
||||
}
|
||||
|
||||
const cardBase =
|
||||
"grid grid-cols-1 gap-y-0.5 items-baseline rounded-lg bg-surface px-2.5 py-2 border-l-[3px] shadow-sm min-h-0 pr-12 relative";
|
||||
const borderByType = {
|
||||
duty: "border-l-duty",
|
||||
unavailable: "border-l-unavailable",
|
||||
vacation: "border-l-vacation",
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Renders a single duty card. If duty has phone or username, wraps in a flip card
|
||||
* (front: type, name, time + "Contacts" button; back: name, ContactLinks, "Back" button).
|
||||
*/
|
||||
export function DutyTimelineCard({ duty, isCurrent }: DutyTimelineCardProps) {
|
||||
const { t } = useTranslation();
|
||||
const [flipped, setFlipped] = useState(false);
|
||||
const frontBtnRef = useRef<HTMLButtonElement>(null);
|
||||
const backBtnRef = useRef<HTMLButtonElement>(null);
|
||||
const hasContacts = Boolean(
|
||||
(duty.phone && String(duty.phone).trim()) ||
|
||||
(duty.username && String(duty.username).trim())
|
||||
);
|
||||
const typeLabel = isCurrent
|
||||
? t("duty.now_on_duty")
|
||||
: t(`event_type.${duty.event_type || "duty"}`);
|
||||
const timeStr = useMemo(
|
||||
() => buildTimeStr(duty),
|
||||
[duty.start_at, duty.end_at]
|
||||
);
|
||||
const eventType = (duty.event_type || "duty") as keyof typeof borderByType;
|
||||
const borderClass = isCurrent ? "border-l-today" : borderByType[eventType] ?? "border-l-duty";
|
||||
|
||||
if (!hasContacts) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
cardBase,
|
||||
borderClass,
|
||||
isCurrent && "bg-[var(--surface-today-tint)]"
|
||||
)}
|
||||
>
|
||||
<span className="text-xs text-muted row-start-1">{typeLabel}</span>
|
||||
<span
|
||||
className="font-semibold min-w-0 row-start-2 whitespace-nowrap overflow-hidden text-ellipsis"
|
||||
title={duty.full_name ?? undefined}
|
||||
>
|
||||
{duty.full_name}
|
||||
</span>
|
||||
<span className="text-[0.8rem] text-muted row-start-3">{timeStr}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="duty-flip-card relative min-h-0 overflow-hidden rounded-lg">
|
||||
<div
|
||||
className="duty-flip-inner relative min-h-0 transition-transform duration-300 motion-reduce:duration-[0.01ms]"
|
||||
style={{
|
||||
transformStyle: "preserve-3d",
|
||||
transform: flipped ? "rotateY(180deg)" : "none",
|
||||
}}
|
||||
>
|
||||
{/* Front */}
|
||||
<div
|
||||
className={cn(
|
||||
cardBase,
|
||||
borderClass,
|
||||
isCurrent && "bg-[var(--surface-today-tint)]",
|
||||
"duty-flip-front"
|
||||
)}
|
||||
>
|
||||
<span className="text-xs text-muted row-start-1">{typeLabel}</span>
|
||||
<span
|
||||
className="font-semibold min-w-0 row-start-2 whitespace-nowrap overflow-hidden text-ellipsis"
|
||||
title={duty.full_name ?? undefined}
|
||||
>
|
||||
{duty.full_name}
|
||||
</span>
|
||||
<span className="text-[0.8rem] text-muted row-start-3">{timeStr}</span>
|
||||
<TooltipProvider delayDuration={300}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
ref={frontBtnRef}
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="absolute right-2 top-1/2 -translate-y-1/2 size-9 rounded-full bg-surface text-accent hover:bg-accent/20"
|
||||
aria-label={t("contact.show")}
|
||||
onClick={() => {
|
||||
setFlipped(true);
|
||||
setTimeout(() => backBtnRef.current?.focus(), 310);
|
||||
}}
|
||||
>
|
||||
<Phone className="size-[18px]" aria-hidden />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="left" sideOffset={8}>
|
||||
{t("contact.show")}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
{/* Back */}
|
||||
<div
|
||||
className={cn(
|
||||
cardBase,
|
||||
borderClass,
|
||||
isCurrent && "bg-[var(--surface-today-tint)]",
|
||||
"duty-flip-back"
|
||||
)}
|
||||
>
|
||||
<span
|
||||
className="font-semibold min-w-0 row-start-1 whitespace-nowrap overflow-hidden text-ellipsis"
|
||||
title={duty.full_name ?? undefined}
|
||||
>
|
||||
{duty.full_name}
|
||||
</span>
|
||||
<div className="row-start-2 mt-1">
|
||||
<ContactLinks
|
||||
phone={duty.phone}
|
||||
username={duty.username}
|
||||
layout="inline"
|
||||
showLabels={false}
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
ref={backBtnRef}
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="absolute right-2 top-1/2 -translate-y-1/2 size-9 rounded-full bg-surface text-accent hover:bg-accent/20"
|
||||
aria-label={t("contact.back")}
|
||||
onClick={() => {
|
||||
setFlipped(false);
|
||||
setTimeout(() => frontBtnRef.current?.focus(), 310);
|
||||
}}
|
||||
>
|
||||
<ArrowLeft className="size-[18px]" aria-hidden />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user