From 130cffd3c1c6538e15ddb9d57abfdb0efb5eb87c Mon Sep 17 00:00:00 2001 From: Bas van Rossem Date: Thu, 19 Feb 2026 16:23:00 +0100 Subject: [PATCH] feat(web): implement Ship List page with create and delete modals Adds ship cards with navigation, create ship modal with initial stats, inline delete confirmation, and supporting CSS for modals and cards. Co-Authored-By: Claude Opus 4.6 --- web/src/components/ships/CreateShipModal.tsx | 98 +++++++++++++++ web/src/components/ships/ShipCard.tsx | 43 +++++++ web/src/components/ui/Modal.tsx | 35 ++++++ web/src/index.css | 121 +++++++++++++++++++ web/src/pages/ShipListPage.tsx | 64 +++++++++- 5 files changed, 360 insertions(+), 1 deletion(-) create mode 100644 web/src/components/ships/CreateShipModal.tsx create mode 100644 web/src/components/ships/ShipCard.tsx create mode 100644 web/src/components/ui/Modal.tsx diff --git a/web/src/components/ships/CreateShipModal.tsx b/web/src/components/ships/CreateShipModal.tsx new file mode 100644 index 0000000..b877f32 --- /dev/null +++ b/web/src/components/ships/CreateShipModal.tsx @@ -0,0 +1,98 @@ +import { useState } from 'react'; +import Modal from '../ui/Modal'; +import { useShipsList } from '../../store/use-ships-list'; + +interface Props { + open: boolean; + onClose: () => void; +} + +export default function CreateShipModal({ open, onClose }: Props) { + const [name, setName] = useState(''); + const [hullMax, setHullMax] = useState('0'); + const [armorMax, setArmorMax] = useState('0'); + const [ac, setAc] = useState('10'); + const [submitting, setSubmitting] = useState(false); + const createShip = useShipsList((s) => s.createShip); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + if (!name.trim()) return; + + setSubmitting(true); + try { + const hMax = parseInt(hullMax) || 0; + const aMax = parseInt(armorMax) || 0; + await createShip({ + name: name.trim(), + hull_max: hMax, + hull_current: hMax, + armor_max: aMax, + armor_current: aMax, + ac: parseInt(ac) || 10, + }); + setName(''); + setHullMax('0'); + setArmorMax('0'); + setAc('10'); + onClose(); + } catch { + // Error handled by store + } finally { + setSubmitting(false); + } + }; + + return ( + +
+ + + + +
+ + +
+
+
+ ); +} diff --git a/web/src/components/ships/ShipCard.tsx b/web/src/components/ships/ShipCard.tsx new file mode 100644 index 0000000..dab84b2 --- /dev/null +++ b/web/src/components/ships/ShipCard.tsx @@ -0,0 +1,43 @@ +import { useNavigate } from 'react-router-dom'; +import type { ShipListItem } from '../../types/ship'; + +interface Props { + ship: ShipListItem; + onDelete: (id: string) => void; +} + +export default function ShipCard({ ship, onDelete }: Props) { + const navigate = useNavigate(); + + const timeAgo = formatTimeAgo(ship.updated_at); + + return ( +
navigate(`/ship/${ship.id}`)}> +
+ {ship.name} + {timeAgo} +
+ +
+ ); +} + +function formatTimeAgo(timestamp: number): string { + const seconds = Math.floor((Date.now() - timestamp) / 1000); + if (seconds < 60) return 'just now'; + const minutes = Math.floor(seconds / 60); + if (minutes < 60) return `${minutes}m ago`; + const hours = Math.floor(minutes / 60); + if (hours < 24) return `${hours}h ago`; + const days = Math.floor(hours / 24); + return `${days}d ago`; +} diff --git a/web/src/components/ui/Modal.tsx b/web/src/components/ui/Modal.tsx new file mode 100644 index 0000000..d531458 --- /dev/null +++ b/web/src/components/ui/Modal.tsx @@ -0,0 +1,35 @@ +import { useEffect, type ReactNode } from 'react'; + +interface ModalProps { + open: boolean; + onClose: () => void; + title: string; + children: ReactNode; +} + +export default function Modal({ open, onClose, title, children }: ModalProps) { + useEffect(() => { + if (!open) return; + const onKey = (e: KeyboardEvent) => { + if (e.key === 'Escape') onClose(); + }; + window.addEventListener('keydown', onKey); + return () => window.removeEventListener('keydown', onKey); + }, [open, onClose]); + + if (!open) return null; + + return ( +
+
e.stopPropagation()}> +
+

{title}

+ +
+ {children} +
+
+ ); +} diff --git a/web/src/index.css b/web/src/index.css index 67071c0..635a182 100644 --- a/web/src/index.css +++ b/web/src/index.css @@ -177,3 +177,124 @@ a:hover { .btn-icon:hover { color: var(--color-text); } + +/* Modal */ +.modal-overlay { + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.7); + display: flex; + align-items: center; + justify-content: center; + padding: var(--spacing-md); + z-index: 100; +} + +.modal-content { + background: var(--color-surface); + border-radius: var(--radius); + padding: var(--spacing-lg); + width: 100%; + max-width: 400px; + max-height: 90dvh; + overflow-y: auto; +} + +.modal-header { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: var(--spacing-md); +} + +.modal-title { + font-size: 1.1rem; + font-weight: 600; +} + +.modal-form { + display: flex; + flex-direction: column; + gap: var(--spacing-md); +} + +.modal-actions { + display: flex; + gap: var(--spacing-sm); + justify-content: flex-end; + margin-top: var(--spacing-md); +} + +/* Form */ +.form-label { + display: flex; + flex-direction: column; + gap: var(--spacing-xs); + font-size: 0.85rem; + color: var(--color-text-muted); +} + +/* Ship List */ +.ship-list-header { + margin-bottom: var(--spacing-md); +} + +.ship-list { + display: flex; + flex-direction: column; + gap: var(--spacing-sm); +} + +.ship-card { + display: flex; + align-items: center; + justify-content: space-between; + background: var(--color-surface); + border: 1px solid var(--color-border); + border-radius: var(--radius); + padding: var(--spacing-md); + cursor: pointer; + transition: background-color 0.15s; +} + +.ship-card:hover { + background: var(--color-surface-hover); +} + +.ship-card-info { + display: flex; + flex-direction: column; + gap: 2px; +} + +.ship-card-name { + font-weight: 600; + font-size: 1rem; +} + +.ship-card-updated { + font-size: 0.8rem; + color: var(--color-text-muted); +} + +.ship-card-delete { + color: var(--color-danger); + font-size: 1.1rem; +} + +.ship-card-delete:hover { + color: #ff6b6b; +} + +/* Utility */ +.loading-text, +.empty-text { + text-align: center; + color: var(--color-text-muted); + padding: var(--spacing-xl) 0; +} + +button:disabled { + opacity: 0.5; + cursor: not-allowed; +} diff --git a/web/src/pages/ShipListPage.tsx b/web/src/pages/ShipListPage.tsx index ee11243..0225276 100644 --- a/web/src/pages/ShipListPage.tsx +++ b/web/src/pages/ShipListPage.tsx @@ -1,12 +1,74 @@ +import { useEffect, useState } from 'react'; import TopBar from '../components/layout/TopBar'; import PageContainer from '../components/layout/PageContainer'; +import ShipCard from '../components/ships/ShipCard'; +import CreateShipModal from '../components/ships/CreateShipModal'; +import { useShipsList } from '../store/use-ships-list'; export default function ShipListPage() { + const { ships, loading, fetchShips, deleteShip } = useShipsList(); + const [showCreate, setShowCreate] = useState(false); + const [pendingDelete, setPendingDelete] = useState(null); + + useEffect(() => { + fetchShips(); + }, [fetchShips]); + + const handleDelete = async (id: string) => { + setPendingDelete(id); + }; + + const confirmDelete = async () => { + if (!pendingDelete) return; + await deleteShip(pendingDelete); + setPendingDelete(null); + }; + return ( <> -

Ship list coming soon...

+
+ +
+ + {loading &&

Loading ships...

} + + {!loading && ships.length === 0 && ( +

No ships yet. Create one to get started!

+ )} + +
+ {ships.map((ship) => ( + + ))} +
+ + setShowCreate(false)} /> + + {/* Simple delete confirmation */} + {pendingDelete && ( +
setPendingDelete(null)}> +
e.stopPropagation()}> +
+

Delete Ship?

+
+

+ This will permanently delete this ship and all its weapons. +

+
+ + +
+
+
+ )}
);