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 <noreply@anthropic.com>
This commit is contained in:
98
web/src/components/ships/CreateShipModal.tsx
Normal file
98
web/src/components/ships/CreateShipModal.tsx
Normal file
@@ -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 (
|
||||
<Modal open={open} onClose={onClose} title="Create Ship">
|
||||
<form onSubmit={handleSubmit} className="modal-form">
|
||||
<label className="form-label">
|
||||
Ship Name *
|
||||
<input
|
||||
type="text"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder="e.g. Astral Clipper"
|
||||
autoFocus
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
<label className="form-label">
|
||||
Hull Max
|
||||
<input
|
||||
type="number"
|
||||
value={hullMax}
|
||||
onChange={(e) => setHullMax(e.target.value)}
|
||||
min="0"
|
||||
/>
|
||||
</label>
|
||||
<label className="form-label">
|
||||
Armor Max
|
||||
<input
|
||||
type="number"
|
||||
value={armorMax}
|
||||
onChange={(e) => setArmorMax(e.target.value)}
|
||||
min="0"
|
||||
/>
|
||||
</label>
|
||||
<label className="form-label">
|
||||
AC
|
||||
<input
|
||||
type="number"
|
||||
value={ac}
|
||||
onChange={(e) => setAc(e.target.value)}
|
||||
min="0"
|
||||
/>
|
||||
</label>
|
||||
<div className="modal-actions">
|
||||
<button type="button" className="btn-secondary" onClick={onClose}>
|
||||
Cancel
|
||||
</button>
|
||||
<button type="submit" className="btn-primary" disabled={submitting || !name.trim()}>
|
||||
{submitting ? 'Creating...' : 'Create Ship'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
43
web/src/components/ships/ShipCard.tsx
Normal file
43
web/src/components/ships/ShipCard.tsx
Normal file
@@ -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 (
|
||||
<div className="ship-card" onClick={() => navigate(`/ship/${ship.id}`)}>
|
||||
<div className="ship-card-info">
|
||||
<span className="ship-card-name">{ship.name}</span>
|
||||
<span className="ship-card-updated">{timeAgo}</span>
|
||||
</div>
|
||||
<button
|
||||
className="btn-icon ship-card-delete"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onDelete(ship.id);
|
||||
}}
|
||||
title="Delete ship"
|
||||
>
|
||||
🗑
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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`;
|
||||
}
|
||||
35
web/src/components/ui/Modal.tsx
Normal file
35
web/src/components/ui/Modal.tsx
Normal file
@@ -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 (
|
||||
<div className="modal-overlay" onClick={onClose}>
|
||||
<div className="modal-content" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="modal-header">
|
||||
<h2 className="modal-title">{title}</h2>
|
||||
<button className="btn-icon" onClick={onClose}>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
fetchShips();
|
||||
}, [fetchShips]);
|
||||
|
||||
const handleDelete = async (id: string) => {
|
||||
setPendingDelete(id);
|
||||
};
|
||||
|
||||
const confirmDelete = async () => {
|
||||
if (!pendingDelete) return;
|
||||
await deleteShip(pendingDelete);
|
||||
setPendingDelete(null);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<TopBar title="Ships" />
|
||||
<PageContainer>
|
||||
<p style={{ color: 'var(--color-text-muted)' }}>Ship list coming soon...</p>
|
||||
<div className="ship-list-header">
|
||||
<button className="btn-primary" onClick={() => setShowCreate(true)}>
|
||||
+ Create Ship
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{loading && <p className="loading-text">Loading ships...</p>}
|
||||
|
||||
{!loading && ships.length === 0 && (
|
||||
<p className="empty-text">No ships yet. Create one to get started!</p>
|
||||
)}
|
||||
|
||||
<div className="ship-list">
|
||||
{ships.map((ship) => (
|
||||
<ShipCard key={ship.id} ship={ship} onDelete={handleDelete} />
|
||||
))}
|
||||
</div>
|
||||
|
||||
<CreateShipModal open={showCreate} onClose={() => setShowCreate(false)} />
|
||||
|
||||
{/* Simple delete confirmation */}
|
||||
{pendingDelete && (
|
||||
<div className="modal-overlay" onClick={() => setPendingDelete(null)}>
|
||||
<div className="modal-content" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="modal-header">
|
||||
<h2 className="modal-title">Delete Ship?</h2>
|
||||
</div>
|
||||
<p style={{ margin: 'var(--spacing-md) 0' }}>
|
||||
This will permanently delete this ship and all its weapons.
|
||||
</p>
|
||||
<div className="modal-actions">
|
||||
<button className="btn-secondary" onClick={() => setPendingDelete(null)}>
|
||||
Cancel
|
||||
</button>
|
||||
<button className="btn-danger" onClick={confirmDelete}>
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</PageContainer>
|
||||
</>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user