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. +

+
+ + +
+
+
+ )}
);