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.
+
+
+
+
+
+
+
+ )}
>
);