feat: add SPA, serve at /app/, update Dockerfile and README

- React + TanStack Router + shadcn/ui SPA under spa/
- serve spa/dist at /app/ with index.html fallback for client routing
- Dockerfile: node build stage for SPA, copy dist into runtime image
- README: document SPA, CORS_ORIGINS env var, architecture entry
- vite base set to /app/, manifest.json paths fixed
This commit is contained in:
2026-06-04 04:20:15 +02:00
parent 15dc0e526b
commit b9c0b10740
153 changed files with 24329 additions and 1 deletions

View File

@@ -0,0 +1,99 @@
import { useRef, useState } from "react"
import { useTranslation } from "react-i18next"
import { useDrag } from "@use-gesture/react"
import { Trash2 } from "lucide-react"
import { Button } from "@/components/ui/button"
import { ConfirmDialog } from "@/components/confirm-dialog"
type SwipeToDeleteProps = {
onDelete: () => void
confirmTitle?: string
confirmDescription?: string
children: React.ReactNode
}
export function SwipeToDelete({
onDelete,
confirmTitle,
confirmDescription,
children,
}: SwipeToDeleteProps) {
const { t } = useTranslation()
const [offsetX, setOffsetX] = useState(0)
const [revealed, setRevealed] = useState(false)
const [confirmOpen, setConfirmOpen] = useState(false)
const ref = useRef<HTMLDivElement>(null)
const bind = useDrag(
({ movement: [mx], active, event }) => {
if (active) {
event.stopPropagation()
setOffsetX(Math.min(0, Math.max(mx, -100)))
} else {
if (mx < -60) {
setRevealed(true)
setOffsetX(-80)
} else {
setRevealed(false)
setOffsetX(0)
}
}
},
{ axis: "x", filterTaps: true, pointer: { capture: true } },
)
function handleDeleteTap() {
setConfirmOpen(true)
}
function handleConfirm() {
setConfirmOpen(false)
setOffsetX(-300)
setTimeout(() => {
onDelete()
setRevealed(false)
setOffsetX(0)
}, 200)
}
function handleTapContent(e: React.MouseEvent) {
if (revealed) {
e.preventDefault()
e.stopPropagation()
setRevealed(false)
setOffsetX(0)
}
}
return (
<div className="relative overflow-hidden rounded-xl">
<div className={`absolute inset-y-0 right-0 flex w-20 items-center justify-center bg-destructive transition-opacity ${offsetX < 0 ? "opacity-100" : "opacity-0"}`}>
<Button variant="ghost" size="icon" onClick={handleDeleteTap} className="text-destructive-foreground hover:text-destructive-foreground">
<Trash2 className="size-5" />
</Button>
</div>
<div
ref={ref}
{...bind()}
onClickCapture={handleTapContent}
className="relative touch-pan-y"
style={{
transform: `translateX(${offsetX}px)`,
transition: offsetX === 0 || Math.abs(offsetX) === 80 || offsetX === -300 ? "transform 200ms ease-out" : "none",
}}
>
{children}
</div>
<ConfirmDialog
open={confirmOpen}
onOpenChange={setConfirmOpen}
title={confirmTitle ?? t("swipeToDelete.removeItem")}
description={confirmDescription}
confirmLabel={t("common.delete")}
onConfirm={handleConfirm}
/>
</div>
)
}